[學習筆記] 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
console.log(`String value: ${value.toUpperCase()}`);
} else {
// 在這裡 TypeScript 已經收窄為 number
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
}

因此,雖然 Boolean(input) 返回的是一個布林值,但它並沒有改變 input 的實際類型,這就導致了 Narrowing Types失敗。

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

如果我們需要更加靈活的 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)

[學習筆記] 使用 Discriminated Unions 解決多狀態問題

前情提要

在日常開發中,我們常需要定義不同狀態下的數據結構,這類需求通常涉及到多個狀態及其對應的屬性。
在 TypeScript 中,如果不加以控制,這些狀態容易變成一個充滿可選屬性的複雜物件,導致程式碼難以管理。
我們有一種更優雅的解決方案 —— Discriminated Unions
它在 TypeScript 中幫助我們避免常見的「bag of optionals」的問題。

主文

假設我們正在開發一個付款流程,並需要一個 PaymentState 類型來描述付款狀態:

1
2
3
type PaymentState = {
status: "processing" | "success" | "failed";
};

此時,我們還需要根據不同狀態保存資料或錯誤訊息,
因此我們為 PaymentState 類型新增了可選的 receiptUrl 和 error 屬性:

1
2
3
4
5
type PaymentState = {
status: "processing" | "success" | "failed";
errorMessage?: string;
receiptUrl?: string;
};

這個定義表面上看起來可行,但在實際使用時容易出現問題。
假設我們定義了一個渲染 UI 的函式 renderUI:

1
2
3
4
5
6
7
8
9
10
11
12
13
const renderUI = (state: PaymentState) => {
if (state.status === "processing") {
return "Payment Processing...";
}

if (state.status === "failed") {
return `Error: ${state.errorMessage.toUpperCase()}`;
}

if (state.status === "success") {
return `Receipt: ${state.receiptUrl}`;
}
};

TypeScript 提示 state.errorMessage 可能是 undefined,
這意味著我們在某些狀態下無法安全地操作 errorMessage 或 receiptUrl 屬性。
這是因為這兩個屬性是可選的,並且沒有和 status 明確地綁定。
這就造成了類型過於鬆散

解決方案:Discriminated Unions

為了解決這個問題,我們可以使用 Discriminated Unions。
這種模式可以將多個狀態拆分為具體的物件,並通過共同的 status 屬性來區分每個狀態。

首先,我們可以將每個狀態單獨建模:

1
2
3
4
type State =
| { status: "processing" }
| { status: "error"; errorMessage: string }
| { status: "success"; receiptUrl: string };

這樣一來,我們就能明確地將 errorMessage 屬性與 error 狀態綁定,
並且 receiptUrl 屬性只會出現在 success 狀態下。
當我們在 renderUI 函式中使用這些屬性時,TypeScript 會自動根據 status 來縮小類型範圍:

1
2
3
4
5
6
7
8
9
10
11
12
13
const renderUI = (state: State) => {
if (state.status === "processing") {
return "Processing...";
}

if (state.status === "error") {
return `Error: ${state.errorMessage.toUpperCase()}`;
}

if (state.status === "success") {
return `Receipt: ${state.receiptUrl}`;
}
};

TypeScript 現在能夠根據 status 確保 errorMessage 在 error 狀態下是一個字串,
並且 receiptUrl 只會在 success 狀態出現。
這大大提高了我們程式碼的安全性和可讀性。

進一步優化:提取類型別名

為了讓程式碼更加清晰,我們可以將每個狀態定義為單獨的類型別名:

1
2
3
4
5
type LoadingState = { status: "processing" };
type ErrorState = { status: "error"; errorMessage: string };
type SuccessState = { status: "success"; receiptUrl: string };

type State = LoadingState | ErrorState | SuccessState;

這樣的結構不僅清晰易讀,也方便日後的擴展。
如果我們需要增加其他狀態,比如 idle 或 cancelled,只需要新增對應的類型別名即可,保持程式碼的擴展性和一致性。

小結

Discriminated Unions 是 TypeScript 中處理多狀態情境的強大工具,特別適合用來解決「可選屬性的大集合」問題。它能夠:

確保每個屬性和狀態間的強關聯,減少錯誤和不一致性。
提升程式碼的可讀性和維護性,並且有助於擴展性。
讓 TypeScript 自動推斷正確的屬性類型,避免不必要的空值檢查。
當你發現程式碼中有過多的可選屬性且沒有強烈關聯時,考慮使用 discriminated unions 來重構並簡化你的類型定義。

參考

(fin)

[學習筆記] 探索 TypeScript 的 Template Literals

前言

什麼是 Template Literals 類型?
在 TypeScript 中,Template Literals 類型是 ES6 引入的一個強大的功能。
Template literals ,主要用來加強字串的操作,它提供了更靈活和易讀的字串插值方式,
這不僅提高了代碼的可讀性,結合上 TypeScript 還可以增加了類型安全性
本篇提供大量範例與適用場景以供測試,有任何問題歡迎提出。

基礎應用(JS/TS 都適用)

字串插值 (String Interpolation)

傳統的字串拼接需要用 + 號,使用 Template Literals 可以讓拼接更簡單。

1
2
3
4
5
6
7
8
9
10
const name = "Lin";
const age = 30;

// 傳統拼接
const message = "My name is " + name + " and I am " + age + " years old.";

// Template Literals
const message2 = `My name is ${name} and I am ${age} years old.`;

console.log(message2);

多行字串 (Multi-line Strings)

Template literals 支援多行字串,可以避免使用 \n 或其他換行符號。

1
2
3
4
5
6
7
8
const poem = `
Roses are red,
Violets are blue,
Sugar is sweet,
And so are you.
`;

console.log(poem);

內嵌表達式 (Embedded Expressions)

可以在字串中插入任意的 JavaScript 表達式,例如函數調用、運算等。

1
2
3
4
const a = 5;
const b = 10;

console.log(`The sum of ${a} and ${b} is ${a + b}`);

條件判斷 (Conditional Statements)

結合三元運算符或簡單的 if 判斷,可以在字串中靈活地處理條件。

1
2
3
4
const loggedIn = true;
const message = `You are ${loggedIn ? "logged in" : "not logged in"}.`;

console.log(message);

標記模板字串 (Tagged Templates)

標記模板允許你在插值之前處理字串,可以用於處理國際化、多語系或安全操作(如避免 XSS 攻擊)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function sanitizeHTML(literals, ...values) {
let result = "";
literals.forEach((literal, i) => {
const value = values[i] ? String(values[i]).replace(/</g, "&lt;").replace(/>/g, "&gt;") : '';
result += literal + value;
});
return result;
}

const userInput = "<script>alert('Hacked!')</script>";
// call function use Tagged Template Literal
const safeOutput = sanitizeHTML`<div>${userInput}</div>`;

console.log(safeOutput); // <div>&lt;script&gt;alert('Hacked!')&lt;/script&gt;</div>

動態生成 HTML 或模版字串

當需要動態生成 HTML 或動態內容時,使用 Template Literals 可以讓結構更清晰。

1
2
3
4
5
6
7
8
9
const list = ["Apple", "Banana", "Cherry"];

const html = `
<ul>
${list.map(item => `<li>${item}</li>`).join('')}
</ul>
`;

console.log(html);

進階應用 with Type

動態生成 URL 路徑

在構建 API 或動態路徑時,Template Literals 結合 TypeScript 類型檢查可以確保參數類型正確,避免拼接錯誤。

1
2
3
4
5
6
7
8
9
10
11
type Endpoint = "/users" | "/posts" | "/comments";

function createURL(endpoint: Endpoint, id: number): string {
return `https://api.example.com${endpoint}/${id}`;
}

const url = createURL("/users", 123);
console.log(url); // https://api.example.com/users/123

// 若傳入不存在的路徑,TypeScript 會報錯
createURL("/invalid", 123); // Error: Argument of type '"invalid"' is not assignable to parameter of type 'Endpoint'.

可以作開發中的路由檢查。

1
2
3
4
5
6
7
8
9
10
11
12
type HTTPMethod = "GET" | "POST" | "PUT" | "DELETE";
type Route = `/api/${string}`;

function request(method: HTTPMethod, route: Route): void {
console.log(`Sending ${method} request to ${route}`);
}

request("GET", "/api/users"); // 正確
request("POST", "/api/posts"); // 正確

// 這會觸發類型檢查錯誤
request("GET", "/invalidRoute"); // Error: Argument of type '"/invalidRoute"' is not assignable to parameter of type 'Route'.

相同的概念也可以來動態生成 SQL 查詢語句,同時確保參數的安全性與類型正確性。

1
2
3
4
5
6
7
8
9
10
11
type TableName = "users" | "posts" | "comments";

function selectFromTable(table: TableName, id: number): string {
return `SELECT * FROM ${table} WHERE id = ${id}`;
}

const query = selectFromTable("users", 1);
console.log(query); // SELECT * FROM users WHERE id = 1

// 傳入錯誤的表名時,會被 TypeScript 類型檢查發現
selectFromTable("invalidTable", 1); // Error: Argument of type '"invalidTable"' is not assignable to parameter of type 'TableName'.

字串聯合類型構造器

一個常見的模式是將Template Literals 類型與聯合類型結合,這樣可以生成所有可能的組合。
例如,假設我們有一組顏色和相應的色調:

1
2
type ColorShade = 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900;
type Color = "red" | "blue" | "green";

我們可以創建一個顏色調色板,代表所有可能的顏色和色調的組合:

1
2
3
type ColorPalette = `${Color}-${ColorShade}`;
let color: ColorPalette = "red-500"; // 正確
let badColor: ColorPalette = "red"; // 錯誤

這樣,我們就得到了 27 種可能的組合(3 種顏色乘以 9 種色調)。
又或者,在樣式生成工具或 CSS-in-JS 的場景中,Template Literals 可以結合類型系統來強化樣式生成工具的正確性。

1
2
3
4
5
6
7
8
9
10
11
12
type CSSUnit = "px" | "em" | "rem";
type CSSProperty = "margin" | "padding" | "font-size";

function applyStyle(property: CSSProperty, value: number, unit: CSSUnit): string {
return `${property}: ${value}${unit};`;
}

console.log(applyStyle("margin", 10, "px")); // margin: 10px;
console.log(applyStyle("font-size", 1.5, "em")); // font-size: 1.5em;

// 若單位或屬性錯誤,會觸發類型檢查錯誤
applyStyle("background", 10, "px"); // Error: Argument of type '"background"' is not assignable to parameter of type 'CSSProperty'.

另一個例子

在構建大型系統時,常常需要動態生成變量名稱或類型,
這時可以使用 Template Literal Types 來幫助構造更具彈性的類型系統。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type Eventa = "click" | "hover" | "focus";
type ElementID = "button" | "input" | "link";

// 動態生成事件處理函數名稱 3x3 種型別檢查
type EventHandlerName<E extends Eventa, T extends ElementID> = `${T}On${Capitalize<E>}Handler`;
const buttonOnClickHandler: EventHandlerName<"click", "button"> = "buttonOnClickHandler";
const buttonOnHoverHandler: EventHandlerName<"hover", "button"> = "buttonOnHoverHandler";
const buttonOnFocusHandler: EventHandlerName<"focus", "button"> = "buttonOnFocusHandler";
const inputOnClickHandler: EventHandlerName<"click", "input"> = "inputOnClickHandler";
const inputOnHoverHandler: EventHandlerName<"hover", "input"> = "inputOnHoverHandler";
const inputOnFocusHandler: EventHandlerName<"focus", "input"> = "inputOnFocusHandler";
const linkOnClickHandler: EventHandlerName<"click", "link"> = "linkOnClickHandler";
const linkOnHoverHandler: EventHandlerName<"hover", "link"> = "linkOnHoverHandler";
const linkOnFocusHandler: EventHandlerName<"focus", "link"> = "linkOnFocusHandler";

const invalidHandler: EventHandlerName<"click", "button"> = "buttonOnHoverHandler"; // Error: Type '"buttonOnHoverHandler"' is not assignable to type '"buttonOnClickHandler"'.

參考網路上的例子

Template Literals 類型允許我們在 TypeScript 中插入其他類型到字符串類型中。
例如,假設我們想要定義一個表示 PNG 文件的類型:

1
type Excel = `${string}.xlsx`;

這樣,當我們為變量指定 Excel 類型時,它必須以 .xlsx 結尾:

1
2
let new_excel: Excel = "my-image.xlsx"; // ✅正確
let old_excel: Excel = "my-image.xls"; // ❌錯誤

當字符串不符合定義時,TypeScript 會顯示錯誤提示,這有助於減少潛在的錯誤。
我們可以確保字符串符合特定的前綴或中間包含特定子字符串。
例如,若要確保路由以 / 開頭,我們可以這樣定義:

1
2
3
type Route = `/${string}`;
const myRoute: Route = "/home"; // ✅正確
const badRoute: Route = "home"; // ❌錯誤

同樣的,如果我們需要確保字符串包含 ?,以便視為查詢字符串,我們可以這樣定義:

1
2
3
type QueryString = `${string}?${string}`;
const myQueryString: QueryString = "search?query=hello"; // 正確
const badQueryString: QueryString = "search"; // 錯誤

此外,TypeScript 還提供了一些內建的實用類型來轉換字符串類型,例如 Uppercase 和 Lowercase,可以將字符串轉換為大寫或小寫:

1
2
type UppercaseHello = Uppercase<"hello">; // "HELLO"
type LowercaseHELLO = Lowercase<"HELLO">; // "hello"

還有 Capitalize 和 Uncapitalize 這兩個實用類型,可以用來改變字符串的首字母大小寫:

1
2
type CapitalizeMatt = Capitalize<"matt">; // "Matt"
type UncapitalizePHD = Uncapitalize<"PHD">; // "pHD"

這些功能展示了 TypeScript 類型系統的靈活性。

結語

Template Literals 類型是一個非常有用的特性,能夠幫助開發者在 TypeScript 中更精確地控制字符串類型。
從定義特定格式的文件名到生成複雜的組合類型,它為代碼提供了更高的可讀性和安全性。
如果你在開發過程中需要處理字符串模式,Template Literals 類型無疑是值得考慮的工具。

參考

(fin)

[學習筆記] TypeScript 字串建議的小技巧

前情提要

最近在寫一個 Hero component,需求是讓使用者能指定英雄的種族。
我們的設計有一些既定的種族,例如 human 和 demon,同時也希望讓使用者能輸入任何自定義的種族名稱。
最初的想法是用以下的定義:

1
type Race = 'human' | 'demon' | string;

並在 Hero 的 props 中使用這個型別:

1
2
3
4
5
6
7
8
9
10
11
export type HeroProps = {
name: string;
race: Race;
}

// component 大概如下
export const Hero = ({ race,name }: HeroProps) => {
return (
<h1>Hero: {name} is {race}</h1>
);
};

這樣一來,使用者可以像這樣使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// src/components/HeroDisplay.tsx

import React from 'react';
import { Hero } from '../components/hero'; // 引入 Hero 型別

const HeroDisplay = () => {
return (
<div>
<Hero name="alice" race="human" />
<Hero name="mark" race="demon" />
<!--more heros -->
</div>
);
};

export default HeroDisplay;

一切看似沒問題,但問題是——在使用 Hero component 時,TypeScript 並不會自動給出 human 或 demon 這樣的建議。

既然我們希望能提供建議,該怎麼解決這個問題呢?

實作記錄

解決方法看起來有些奇怪,我們可以透過將字串類型與一個空的物件相交來達成目標:

1
type Race = 'human' | 'demon' | (string & {});

這樣一來,在使用 Hero component 時,TypeScript 就會正確地給出 primary 和 secondary 的建議。
為什麼這會起作用?這其實是 TypeScript 編譯器的一個小「怪癖」。
當你把字串常值類型(例如 “human”)與字串類型(string)進行聯集時,
TypeScript 會急切地將其轉換為單純的 string,因此在 Hover 時會看到類似這樣的結果:

1
2
3
type Race = 'human' | 'demon' | string ;
// Hover 時會顯示:
type Race = string

換句話說,TypeScript 在使用前就忘記了 human 和 demon。
而透過與空物件 & {} 進行相交,我們能「欺騙」 TypeScript,讓它在更長時間內保留這些字串常值類型。

1
2
3
type Race = 'human' | 'demon' | (string & {});
// Hover 時會顯示:
type Race = 'human' | 'demon' | (string & {});

這樣,我們在使用 Race 型別時,TypeScript 就能記得 human 和 demon,並給出對應的建議。

值得注意的是,string & {} 實際上和單純的 string 是相同的類型,因此不會影響我們傳入的任何字串:

1
2
<Hero name="alice" race="human" />
<Hero name="mark" race="demon" />

這感覺像是在利用 TypeScript 的漏洞。
不過,TypeScript 團隊其實是知道這個「技巧」的,他們甚至針對這種情況進行測試。
或許將來,TypeScript 會原生支援這樣的功能,但在那之前,這仍是一個實用的小技巧。

範例 Code

小結

總結來說,當你想允許使用者輸入任意字串但又想提供已知字串常值的自動補全建議時,可以考慮使用 string & {} 這個技巧:

它防止 TypeScript 過早將 string | “literal” 合併成單純的 string。
實際使用時行為與 string 一樣,但會多提供自動補全功能。
這或許不是最正式的解法,但目前仍是一個可以信賴的方式。
也許未來 TypeScript 能夠原生解決這個問題,但在那之前,這個小技巧可以為開發帶來便利。

(fin)

[學習筆記] 淺談 TypeScript 方法簡寫與物件屬性語法的差異

前情提要

在 TypeScript 中,我們常見到兩種方法的定義方式:
**方法簡寫語法(Method Shorthand Syntax) 和 物件屬性語法(Object property syntax)**。
乍看之下,這兩種語法非常相似,但實際上,Method Shorthand Syntax 在類型檢查上的表現可能會導致潛在的運行時錯誤。
本篇將討論這個問題,並提供避免這類錯誤的最佳做法。

本文

在 TypeScript 中,我們可以用兩種不同的方式定義物件的方法:

Method Shorthand Syntax:

1
2
3
interface Animal {
makeSound(): void;
}

Object property syntax:

1
2
3
interface Animal {
makeSound: () => void;
}

兩者表面上似乎只是不同的語法選擇,但實際上,它們在類型檢查時有著不同的行為。
當我們使用 Method Shorthand Syntax 時,TypeScript 的類型檢查會出現雙變性(Bivariance),
這意味著參數的類型檢查會變得寬鬆,允許接受與定義不完全符合的類型。

問題例子
讓我們看一個新的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
interface Character {
attack(character: Character): void;
}

interface Monster extends Character {
counterattack: () => void;
}

const hero: Character = {
attack(victim: Monster) {
// victim do something
},
};

const goblin: Character = {
attack() {},
};

hero.attack(goblin); // 編譯時無錯,運行時錯誤!

在這個例子中,我們有一個 Character 介面和一個繼承它的 Monster 介面。
Monster 介面具有一個 counterattack 方法,這表示怪物應該能夠進行反擊。
接著,我們定義了一個 hero 物件,它可以攻擊任何 Monster 角色並呼叫它的 counterattack 方法。
然而,我們創建了一個 goblin 物件,這個物件實現了 Character 介面,但並不符合 Monster 介面的要求。
當我們試圖讓 hero 攻擊 goblin 時,雖然 TypeScript 在編譯時不會報錯,但在運行時會導致錯誤,因為 goblin 並沒有實作 counterattack 方法。
這是由於參數類型的雙變性(Bivariance)造成的問題,因為 hero.attack 方法的參數類型過於寬鬆,導致運行時出現預期外的行為。

解決方案
為了解決這個問題,應該使用物件屬性語法來定義方法,這樣 TypeScript 會進行更嚴格的類型檢查,並能在編譯時捕捉到類型不匹配的問題。

改寫後的範例:

1
2
3
4
5
6
7
8
9
10
11

interface Character {
attack: (character: Character) => void; // 改用 Object property syntax
}

// 這裡會報錯,因為 attack 應該傳入的是 Character
const hero: Character = {
attack(victim: Monster) {
// victim do something
},
};

在這個改寫後的範例中,TypeScript 會在編譯時警告我們 attack 方法的參數類型不匹配,從而避免了運行時錯誤。

結語

TypeScript 的類型系統非常強大,但也有一些容易被忽略的陷阱。
雙變性可能看似便利,但它也可能導致運行(Runtime)時的錯誤。
為了減少這類錯誤的風險,我們應該使用物件屬性語法來定義方法,
可以讓 TypeScript 進行更加嚴格的類型檢查,從而在開發過程中及早發現問題。

參考

(fin)

[學習筆記] 一些復盤的方法

引言

吾日三省吾身:為人謀而不忠乎?
與朋友交而不信乎?傳不習乎?
—論語‧學而

在現代化的管理手段,反省、復盤是一個系統化系統中重要的一環,本文將記錄我所知道一些復盤方法。

為什麼要復盤?

復盤的核心目的是通過回顧和分析過去的行動,提升未來的表現。復盤的主要理由有三個:

  1. 知其然與所以然
    幫助了解實際情況及其背後的原因,不僅知道「發生了什麼」,還能明白「為什麼會發生」。
  2. 從錯誤中學習,避免重蹈覆轍,進而提升表現。
  3. 總結經驗,持續改善;系統化地總結經驗教訓,轉化為改善的具體步驟或方法論。

經典的復盤方法

  1. PDCA 循環
    PDCA 是一種持續改進的工具,適用於長期項目和計劃管理。其四個步驟為:

    • Plan(計劃):設定目標和制定計劃。
    • Do(執行):實施計劃中的行動。
    • Check(檢查):檢查實施結果,與計劃進行對比。
    • Act(行動):根據檢查結果進行改進或調整,進入下一個循環。

    這種方法幫助我們在行動中不斷檢查和調整,達到持續改進的目的。

  2. SMART 目標
    SMART 是一種設定明確目標的框架,使復盤更具體化。其五個原則為:

    • Specific(具體的):目標應該明確,針對某一具體領域。
    • Measurable(可衡量的):確定可量化的指標。
    • Achievable(可達成的):設定合理且可實現的目標。
    • Relevant(相關的):確保目標與團隊或個人的發展方向一致。
    • Time-bound(有時限的):設定明確的時間範圍。

    SMART 方法幫助我們設立清晰且可衡量的目標,使復盤更具方向性和實效性。

  3. GARI 復盤法
    GARI 是一種結構化的復盤方式,幫助我們從整體回顧、分析到總結經驗教訓。
    其具體過程如下:

    • Goal(回顧目標):首先回顧當初設定的目標或期望,這有助於明確我們的初衷,為後續的結果分析奠定基礎。
    • Result(評估結果):對比實際結果與當初目標,找出成功的亮點以及不足之處。
    • Analysis(分析原因):深入分析事情成功或失敗的原因,這個過程需要考慮主觀和客觀的因素,例如個人行動、外部環境等。
    • Insight(總結規律):最後,從分析中總結出可供未來參考的經驗和規律,找出更符合本質規律的方法。

    這是一個全面的工具,適合用來結構化地反思和提升,幫助我們在下一次行動中表現得更好

  4. KISS 反思法
    KISS 是一種簡潔有效的反思框架,著眼於具體行動的調整與改進。
    它分為以下四個部分:

    Keep(需要保持的):回顧過程中哪些做法是有效的,應繼續保持並發揚光大。
    Improve(需要改進的):哪些環節還有提升的空間,並制定具體的改進計劃。
    Stop(需要停止的):有哪些不必要或無效的做法,應該果斷停止。
    Start(需要開始的):是否有新的嘗試或做法需要納入,以應對未來挑戰。

    KISS 反思法簡明扼要,能夠快速聚焦在關鍵點上,幫助我們更具效率地進行行動調整。

分析法

  1. SWOT 分析
    SWOT 分析是一種評估內外部環境的工具,幫助識別成功因素和改進空間。其四個要素為:

    • Strengths(優勢):識別內部的優勢。
    • Weaknesses(劣勢):了解內部的劣勢。
    • Opportunities(機會):評估外部可能帶來的機會。
    • Threats(威脅):分析外部的潛在威脅。
      這種方法可以幫助我們全面了解內外部環境,制定有效的策略。
  2. 魚骨圖分析(Ishikawa Diagram)
    魚骨圖用於分析問題的各種潛在原因,圖形像魚骨,因此得名。其步驟包括:
    問題作為「魚頭」,根據不同類別(如人員、設備、流程等)畫出「魚刺」。
    每條「魚刺」代表可能的原因,進行討論分析。
    這種方法能夠系統化地分析問題的多種可能原因。

  3. 德魯克的五個問題(Drucker’s five questions)
    這是一種基於商業戰略方法。其問題包括:

    • 我們的使命是什麼?What is your mission?
    • 我們的顧客是誰?Who is your customer?
    • 顧客真正重視的是什麼?What does your customer value?
    • 我們的成果是什麼? What results do you seek?
    • 我們的計劃是什麼? What is your plan?
      有一種說法是上面五個問題可以被濃縮成我們怎樣满足顧客的需求?
  4. 5 Why 分析法
    5 Why 分析法是一種深挖問題根源的方法,通過不斷追問「為什麼」來找到問題的核心原因。其步驟包括:
    從問題開始,問「為什麼」。
    每次回答後,再次問「為什麼」,重複五次或直到找到根本原因。
    這種方法能夠幫助我們深入分析問題的根本原因,避免表面化的解決方案。

其他參考的方法

  • AAR(After Action Review)
  • KPT(Keep, Problem, Try)
  • OKR(Objectives and Key Results)

結論

復盤能夠幫助我們從過去的行動中汲取智慧,避免重蹈覆轍,並促進持續進步。
「學而不思則罔,思而不學則殆」,復盤正是「思」與「學」的結合。
前面引言的白話文是:我每天都再三自我反省:替別人做事有沒有盡心竭力?
和朋友相處有沒有言而無信?學習到的道理,我有沒有好好認真實踐?

(fin)

[實作筆記] Azure Function Queue Trigger 開發(以Python為例)

前情提要

Azure Functions 提供了在雲端執行無伺服器函數的強大能力,
但在本地環境中開發和測試這些函數可以大大提高開發效率。
為了模擬 Azure Storage 服務,我們可以使用 Azurite,這是一個 Storage 的本地模擬器。
本文將記錄我如何在本機上設置建立本機的 Azurite Queue 進行開發。

註:本文假設你已具備建立 Azure Functions 的前置基礎

實作記錄

1. 建立和設定本機開發環境

安裝 Azurite

Azurite 是用於模擬 Azure Storage 服務的本地工具,您可以通過以下命令進行安裝:

1
npm install -g azurite

啟動 Azurite

啟動 Azurite 並指定 Storage 位置和日誌文件。

下面的語法會建立 .azurite 資料夾為 Azurite 的默認資料夾,
你可以根據需要修改路徑或刪除並重建此資料夾:

1
azurite --silent --location ./.azurite --debug ./.azurite/debug.log

啟動後,你會看到以下輸出,表示 Azurite 成功啟動並監聽相關端口:

1
2
3
Azurite Blob service is starting at http://127.0.0.1:10000
Azurite Queue service is starting at http://127.0.0.1:10001
Azurite Table service is starting at http://127.0.0.1:10002

設定本機 Azure Storage 連線字串

為了方便操作本機 Azure Storage,
我們需要設置 AZURE_STORAGE_CONNECTION_STRING 環境變數:
這裡要查看微軟官方文件取得地端連線字串
你們可以看到它包含了一組 AccountKey 與 Account(devstoreaccount1)
這個例子中我們只使用了 Azurite Queue Service

1
export AZURE_STORAGE_CONNECTION_STRING="DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;QueueEndpoint=http://127.0.0.1:10001/devstoreaccount1;"

開發完成後,記得刪除此連線字串:

1
unset AZURE_STORAGE_CONNECTION_STRING

常用語法

檢視所有 Queue

1
az storage queue list

建立 Local Queue

1
az storage queue create --name myqueue

建立資料到指定 Queue 中, 下面的例子會建立一包 JSON 檔,當然你也可以使用純文字(text)

1
az storage message put --queue-name myqueue --content "{\"message\": \"Hello, World\!\", \"id\": 123, \"status\": \"active\"}"

顯示前 5 筆指定 Queue 中的資料

1
az storage message peek -q myqueue --num-messages 5

取出 Queue 中的資料

1
az storage message get --queue-name myqueue --num-messages 1

刪除 Queue 中的資料

1
az storage message delete --queue-name myqueue --id <message-id> --pop-receipt <pop-receipt>

補充說明:

popReceipt 是 Azure Queue 中用來確認消息取出和刪除操作的唯一識別碼。
當取出消息時,Azure 會返回 popReceipt,確保只有取出的客戶端能夠刪除該消息。
如果 popReceipt 顯示為 null,通常表示消息尚未取出或命令不正確。
要獲取 popReceipt,使用 az storage message get 命令取出訊息。

2. Azure Functions 的本機開發與執行

啟動 Azure Functions

當本機環境設置完成後,你可以使用以下命令來啟動 Azure Functions:

1
func start

這條命令會啟動你的本地 Azure Functions 執行環境,使你可以在本機上測試和調試你的函數。

3. 加碼,代碼檢查和格式化

安裝與配置 Pylint

Pylint 是一個 Python 代碼靜態分析工具,可以檢查代碼中的錯誤和不符合最佳實踐的地方。首先,安裝 Pylint:

1
pip install pylint

配置 Pylint,創建或修改 .pylintrc 文件來包含你的檢查規則:

1
2
3
4
5
[MESSAGES CONTROL]
disable=C0114,C0115,C0116

[FORMAT]
max-line-length=120

使用 Pylint 進行代碼檢查

運行以下命令來檢查所有 Python 文件:

1
pylint *.py

安裝與使用 Black 進行代碼格式化

Black 是一個自動格式化 Python 代碼的工具,能夠保持代碼風格的一致性。首先,安裝 Black:

1
pip install black

格式化整個專案的所有 Python 文件:

1
black .

整合檢查和格式化工具

你可以將代碼檢查和格式化工具整合到一個命令中,這樣可以簡化工作流程:

1
pylint *.py && black .

例外

在 Azure Functions 中,某些函數參數不符合 Pylint 的命名規則,這可能會導致部署失敗。你可以忽略這些特定的 Pylint 警告,例如:

1
2
# pylint: disable=C0103
def dispatch_worker(inputQueue: func.QueueMessage, watchQueue: func.Out[str]):

問題與排除

  • 如果 Azurite 無法啟動,請檢查是否已正確安裝 Azurite 以及是否有其他應用程序佔用了相關端口。
  • 如果 Azure Functions 無法啟動,請確保所有相關的配置文件和依賴項已正確設置。

參考

(fin)

[踩雷筆記] Gitlab 整合 Azure DevOps Pipeline 以 python on Azure Functions 為例

前情提要

我目前主要使用 GitLab 進行版本控制和持續集成(CI/CD),
主要整合 Google Cloud Platform (GCP) ,許多專案都運行在 GCP 上。
因商務需求最近開始探索第二朵雲 Azure。
雖然 Azure DevOps 也有提供 CI/CD 與 Repo 的解決方案;
但為了減少邏輯與認知負擔,我希望能將 GitLab 與 Azure DevOps Pipeline 進行整合。
具體來說,這次要在 Azure Functions 上部署 Python 應用程式,
我想要 RD 往 Gitlab 推送並執行 CI/CD 就好,而不用特別因為服務在不同的雲上,而需要推送到不到同 Repo 中 。
面對這樣的需求,下面是我找到的解決方案。

實作

以這次的例子來說,我需要控管 Azure 的 Serverless 解決方案「Azure Functions」的程式。
但是 Azure DevOps Pipeline 有相當高度的整合 Azure Cloud,只要能將程式推送到 Azure DevOps Repo,
部署就會相當簡單,而無需處理繁鎖的授權問題
CI/CD 流程大致如下

  • 建立相對應的權限與憑証並提供給 Gitlab-Runner
  • RD 推送新版本程式給 Gitlab,觸發 Gitlab-Runner
  • Gitlab-Runner 執行測試、建置等相關作業後,部署到 Azure Functions
  1. 設置 Azure DevOps Pipeline

    1. 選擇 New pipeline
    2. Other Git
    3. 設定連線方式 & 選擇分支
      1. Connection name (任意命名)
      2. Repo URL 輸入 Gitlab Repo URL
      3. User Name (任意命名)
      4. Password / Token Key (Gitlab PAT,需注意效期)
    4. 使用「Azure Functions for Python」Template
      • Build extensions
      • Use Python 3.10(可以更換合適的版本)
      • Install Application Dependencies
      • Archive files
      • Publish Artifact: drop
    5. 追加 Agent job Task
      • 搜尋「Azure Functions Deploy」
      • 填寫
        • Azure Resource Manager connection
          • Manage > Service Connection > New Service Connection > Azure Resource Manager > Service principal(automatic)
            • Service connection name
            • Description (optional)
          • 也可以選擇 > Service principal (manual),需要先加上 App registrations 具體流程如下:
            • 在 Azure Portal 上建立一個新的 Azure Registration。
            • 選擇 Certificates & secrets,建立一組 Certificates & secrets。
            • 回到 Service principal (manual)
              • Subscription Id
              • Subscription Name
              • Service Principal Id (App registrations Client secrets 的 Secret ID)
              • Service principal key (App registrations Client secrets 的 Value)
              • Tenant ID
        • App type (我的情況是選 Function App on Linux)
        • Azure Functions App name
  2. 配置 GitLab CI/CD

    • 在 GitLab 中,建立 .gitlab-ci.yml 如下:
    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
    image: debian:stable-slim
    variables:
    AZURE_PIPELINE_NAME: "dispatch-worker-deploy-pipeline"

    before_script:
    - apt-get update && apt-get install -y curl jq

    stages:
    - deploy

    trigger_pipeline:
    stage: deploy
    script:
    - |
    json=$(curl -u $AZURE_DEVOPS_USER:$AZURE_DEVOPS_PAT \
    -H "Content-Type: application/json" \
    "https://dev.azure.com/Aiplux/Inpas/_apis/build/definitions?api-version=6.0")
    id=$(echo $json | jq -r --arg pipeline_name "$AZURE_PIPELINE_NAME" '.value[] | select(.name==$pipeline_name) | .id')
    echo -e "\033[1;33mPipeline: $AZURE_PIPELINE_NAME ID is $id\033[0m"
    RESPONSE_CODE=$(curl -X POST "https://dev.azure.com/My_Organization/My_Project/_apis/build/builds?api-version=6.0" \
    --data '{"definition": {"id": '$id'}}' \
    -u ${AZURE_DEVOPS_USER}:${AZURE_DEVOPS_PAT} \
    -H 'Content-Type: application/json' \
    -w "%{http_code}" -o /dev/null -s)
    if [ "$RESPONSE_CODE" -ne 200 ]; then
    echo -e "\033[1;31mRequest failed with status code $RESPONSE_CODE\033[0m"
    exit 1
    fi

    rules:
    - if: '$CI_COMMIT_BRANCH == "main"'

    可以看到 AZURE_DEVOPS_USER 與 AZURE_DEVOPS_PAT 兩個參數,
    可以在登入 Azure DevOps 後,在 User Settings >> Personal Access Tokens 取得,
    實務上由管理者提供,但是有時效性,仍然需要定期更新(1年),應該有更簡便的方法才對。

完成以上的設定後,只要推送到 Gitlab Repo 的 main 分支,就會觸發 Azure DevOps Pipeline 部署。

踩雷

在整合過程中遇到了一個問題。
儘管使用了 Azure Pipeline 提供的官方模板,部署過程依然出現了錯誤。
具體而言並沒有明顯的錯誤,但是 Log 會記錄

1 function found
0 function loaded

導致 Azure Functions Apps 無法正常工作。

解法

參考

在 Azure Functions for Python 其中一步驟 Install Application Dependencies
Template 如下:

1
2
3
4
python3.6 -m venv worker_venv
source worker_venv/bin/activate
pip3.6 install setuptools
pip3.6 install -r requirements.txt

需要修改成才能作用,不確定 Azure 會不會提出修正或新的 template

1
2
3
4
python3.10 -m venv .python_packages
source .python_packages/bin/activate
pip3.10 install setuptools
pip3.10 install --target="./.python_packages/lib/site-packages" -r ./requirements.txt

查閱了 Azure 和 GitLab 的官方文檔以及技術社群中的討論,找到了有效的解決方案。
通過修改 template,成功解決了部署問題,
GitLab 和 Azure DevOps Pipeline 的整合成功。

參考

(fin)

Please enable JavaScript to view the LikeCoin. :P