Fastify是一個運行在Node.js上的Web框架,注重開發體驗和低開支(overhead),提供完整的Web框架特性,但也保有良好的效能,效能要比Express框架還好上不少。Fastify支援TypeScript語言,筆者建議使用TypeScript來開發Fastify應用程式。然而,要建立出一個完整TypeScript專案是一件繁瑣的事情,我們會需要安裝多種套件及工具並撰寫設定檔和程式碼,來使專案能符合我們自己的開發習慣。在這篇文章中,筆者要分享自己建立TypeScript專案並使用Fastify框架來開發Web服務的方式。



建立使用 Fastify 框架的 TypeScript 專案

建立 TypeScript 應用程式專案

請參考以下連結的文章來建立TypeScript應用程式專案,要加入Jest測試框架。

加入 Fastify 和其它會用到的 Node.js 套件

在專案根目錄中執行以下指令來安裝套件:

pnpm i fastify glob pino pino-pretty ts-essentials

fastify套件即是Fastify框架的核心套件;glob是方便用來匹配檔案路徑的套件;pino是輸出日誌(log)的套件,筆者習慣自行控制日誌的輸出,而不會讓Fastify框架本身輸出日誌;pino-pretty是用來美化日誌輸出的套件,可以加顏色上去;ts-essentials套件提供了很多方便的型別,這篇文章會用到其中的DeepReadonly

接著按照以下的方式修改package.json中的start:dev腳本,開啟Node.js的inspect的功能,方便在任何時候於開發環境將我們的Web服務附加至JavaScript的Debugger中。

{
    "scripts": {
        ...

        "start:dev": "NODE_ENV=development nodemon --signal SIGINT --exec \"node --inspect=0.0.0.0:9229 --loader ts-node/esm src/app.ts\" -e 'ts,json'",

        ...
    }
}

如果您有使用run-with-node-env套件,請自行將NODE_ENV=development改為run-with-node-env development

另外,之所以要讓nodemon指令加上--signal SIGINT參數是為了要解決重啟失敗而導致連接埠佔用的問題,並且也可以讓之後要介紹的「優雅的關機」的機制能正常作用於當檔案發生變化使nodemon自動重啟時。SIGINT即在終端機下對執行中的程式按下Ctrl + c時所發出的中斷信號。

建立基本的目錄和程式碼檔案
目錄

首先建立出src/schemassrc/controllerssrc/routes這三個目錄。

Linux或是macOS環境可以直接執行以下指令來建立:

mkdir -p src/schemas src/controllers src/routes

src/schemas目錄用來放置各種資料的JSON Schema和相關程式碼;src/controllers目錄用來放置處理HTTP請求的程式碼,即Fastify的路由處理程序(handler);src/routes目錄用來放置註冊路由的程式碼,並對每個路由設定JSON Schema。

Logger

然後要建立src/logger.ts,內容如下:

import cluster from "node:cluster";

import pinoPkg from "pino";
import pinoPrettyPkg from "pino-pretty";

const pino = pinoPkg as unknown as typeof pinoPkg.pino;
const pinoPretty = pinoPrettyPkg as unknown as typeof pinoPrettyPkg.PinoPretty;

let logger;

let base: Record<string, unknown> | undefined = undefined;

if (cluster.isWorker) {
    base = { pid: process.pid };
}

switch (process.env.NODE_ENV) {
    case "production":
        logger = pino({ level: process.env.LOG_LEVEL ?? "info", base }, pinoPretty());
        break;
    case "test":
        logger = pino({ level: process.env.LOG_LEVEL ?? "warn", base }, pinoPretty({ sync: true }));
        break;
    default:
        logger = pino({ level: process.env.LOG_LEVEL ?? "debug", base }, pinoPretty());
        break;
}

export const Logger = logger;

這個src/logger.ts可用來代替console.log的用途。日誌等級由低到高分別為tracedebuginfowarnerrorfatal。藉由LOG_LEVEL環境變數可以設定要輸出的日誌的最低等級,而NODE_ENV環境變數的值則會影響到預設輸出的日誌的最低等級。不過每個人看日誌有不同的習慣,您可以根據自己的喜好來修改這個檔案。

路由

筆者將註冊路由的程式碼都規劃到src/routes目錄下,檔名格式必須為xxx.route.ts,並利用glob套件和require函數將檔案動態引入進來。且為了進一步限制xxx.route.ts的撰寫方式,確保動態引入不容易出問題,又撰寫了Route介面,讓xxx.route.ts暴露(export)的預設值得是Route型別才行。

Route介面寫在src/routes/route.ts檔案中,內容如下:

export interface Route {
    readonly setRoutes: (app: import("fastify").FastifyInstance) => void
}

讀取xxx.route.ts的程式寫在src/app.routes.ts檔案中,內容如下:

import { dirname } from "node:path";
import { fileURLToPath } from "node:url";

import { globSync } from "glob";

const __dirname = dirname(fileURLToPath(import.meta.url));

export const registerRoutes = async (app: import("fastify").FastifyInstance) => {
    const filePaths = globSync("routes/**/*.route.@(js|ts)", { cwd: __dirname });

    for (const filePath of filePaths) {
        const requirePath = filePath.substring(0, filePath.length - 3);

        // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
        const route = (await import(`./${requirePath}.js`)).default as import("./routes/route.js").Route;

        void app.register((app, _opts, done) => {
            route.setRoutes(app);
            done();
        });
    }
};
Fastify 實體

建立Fastify實體的程式寫在src/app.fastify.ts檔案中,內容如下:

import { fastify } from "fastify";
import { FastifySchemaValidationError } from "fastify/types/schema.js";

import { registerRoutes } from "./app.routes.js";
import { Logger } from "./logger.js";

const app = fastify({ ignoreTrailingSlash: true });

app.setErrorHandler((error, _req, res) => {
    Logger.trace(error);

    const statusCode = error.statusCode ?? 500;

    let validation: {
        context: string,
        errors: FastifySchemaValidationError[],
    } | undefined;

    if (statusCode >= 500 && statusCode < 600) {
        Logger.error(error.stack);
    } else if (statusCode === 415) {
        Logger.warn(error.stack);
    } else if (typeof error.validation !== "undefined") {
        error.validation.forEach((e) => {
            delete e.message;
        });

        validation = {
            // eslint-disable-next-line no-extra-parens
            context: (error as unknown as { validationContext: string }).validationContext,
            errors: error.validation,
        };
    }

    void res.code(statusCode);

    void res.send({ validation });
});

app.setNotFoundHandler((_req, res) => {
    void res.status(404);

    void res.send({});
});

app.addHook("onResponse", (req, res, done) => {
    const method = req.method;

    let realIP: string | null;

    let ip = req.headers["x-real-ip"];

    if (typeof ip === "undefined") {
        ip = req.headers["x-forwarded-for"];

        if (typeof ip === "undefined") {
            realIP = req.socket.remoteAddress ?? null;
        } else if (Array.isArray(ip)) {
            realIP = ip[0];
        } else {
            const ips = ip.split(",");

            realIP = ips[0];
        }
    } else if (Array.isArray(ip)) {
        realIP = ip[0];
    } else {
        realIP = ip;
    }

    const url = req.raw.url ?? null; // should not be null

    const userAgent = req.headers["user-agent"] ?? "";

    const statusCode = res.statusCode;

    // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
    Logger.info(`${method} ${url} ${statusCode} - ${userAgent} ${realIP}`);

    done();
});

await registerRoutes(app);

export default app;

在這個檔案中,設定了Fastify對於被拋出(throw)的錯誤的處理方式。錯誤時會回應一個空的JSON物件,不過如果是由Fastify schema所產生的資料驗證錯誤,則詳細錯誤資訊會被放進這個JSON物件的validation欄位中。

另外在這個檔案中,還設計了在回應的時候要輸出的日誌格式。實際輸出內容看起來會像是以下這樣:

[13:34:41.345] INFO: GET /10 404 - Mozilla/5.0 (X11; Linux x86_64; rv:105.0) Gecko/20100101 Firefox/105.0 127.0.0.1

這個檔案只會建立出Fastify實體,並不會進行TCP等Socket的監聽。在撰寫測試的時候應直接引入這個檔案,並使用Fastify實體自帶的inject方法來發送請求,就不必透過TCP等Socket連線的方式來傳送資料,稍候會介紹要怎麼撰寫Fastify Web服務的測試。

程式進入點

將該有的模組都備齊後,就可以開始撰寫程式進入點了。我們的TypeScript應用程式專案的程式進入點是src/app.ts,內容如下:

import cluster from "node:cluster";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

import app from "./app.fastify.js";
import { Logger } from "./logger.js";

const PORT = parseInt(process.env.PORT ?? "3000");

let instanceNumber = parseInt(process.env.INSTANCE_NUMBER ?? "0");

if (process.env.NODE_ENV === "production") {
    if (instanceNumber === 0) {
        instanceNumber = os.cpus().length;
    }
}

if (instanceNumber > 1 && cluster.isPrimary) {
    if (process.env.SOCKET_PATH) {
        const socketPath = path.resolve(process.env.SOCKET_PATH);
    
        let stat;
    
        try {
            stat = fs.statSync(socketPath);
        } catch (err) {
            // do nothing
        }
    
        if (stat) {
            if (stat.isSocket()) {
                try {
                    fs.unlinkSync(socketPath);
                } catch (err) {
                    Logger.error(err);
                }
            } else {
                Logger.error(`${socketPath} exists but it is not a socket file`);
                process.exit(1);
            }
        }
    }

    for (let i = 0;i < instanceNumber;++i) {
        cluster.fork();
    }
} else if (process.env.SOCKET_PATH) {
    const socketPath = path.resolve(process.env.SOCKET_PATH);

    app.listen({ path: socketPath }, (err) => {
        if (err) {
            Logger.error(err);
            return;
        }

        fs.chmodSync(socketPath, 0o777);

        Logger.info(`HTTP server started at ${socketPath}`);
    });
} else {
    app.listen({ host: "0.0.0.0", port: PORT }, (err) => {
        if (err) {
            Logger.error(err);
            return;
        }

        Logger.info(`HTTP server started at http://localhost:${PORT}`);
    });
}

程式進入的時候,會先依據INSTANCE_NUMBER環境變數來決定要fork多少行程出來,行程數量愈多,服務的吞吐量就愈大。如果沒有設定INSTANCE_NUMBER環境變數,在生產環境下,會使用等同於邏輯核心數量的行程來啟動服務;在非生產環境下,則會只會使用一個行程來啟動服務。在啟動服務之前,會判斷SOCKET_PATH環境變數有沒有設定,有的話就會啟動UDS(Unix Domain Socket)服務,沒有的話就是啟動TCP服務(監聽連接埠3000)。

範例程式

以下是src/controllers目錄底下的程式範例。

import { FastifyReply, FastifyRequest } from "fastify";

export const ExampleController = {
    hello: (req: FastifyRequest<{Querystring: { name?: string } }>, res: FastifyReply): void => {
        void res.send(`Hello, ${req.query.name ?? "stranger"}!`);
    },

    helloPost: (req: FastifyRequest<{Body?: {name?: string}}>, res: FastifyReply): void => {
        void res.send(`Hello, ${req.body?.name ?? "stranger"}!`);
    },
};

這就是一個Hello World程式,可以在請求中傳入一個名稱來設定回傳時要打招呼的對象。hello會去讀取請求網址中的Query部份,helloPost則是會去讀取HTTP請求中的Body。

接著在src/schemas目錄底下定義資料的JSON Schema。

import { DeepReadonly } from "ts-essentials";

const helloSchema = {
    type: "object",
    nullable: true,
    properties: { name: { type: "string" } },
};

export const HelloSchema: DeepReadonly<typeof helloSchema> = helloSchema;

在暴露的時候利用DeepReadonly型別將JSON Schema包裹起來,避免在其它地方被意外修改。

最後是在src/routes目錄底下建立路由。

import { FastifyInstance } from "fastify";


import { ExampleController } from "../controllers/example.controller.js";
import { HelloSchema } from "../schemas/example.schema.js";

import { Route } from "./route.js";

const ExampleRoute: Route = {
    setRoutes(app: FastifyInstance): void {
        app.get("/hello", { schema: { querystring: HelloSchema } }, ExampleController.hello);
        app.post("/hello", { schema: { body: HelloSchema } }, ExampleController.helloPost);
    },
};

// eslint-disable-next-line import/no-unused-modules
export default ExampleRoute;

至此範例程式就可以動作了。要測試它的話,我們可以再撰寫以下的測試程式:

import app from "../src/app.fastify.js";

describe("say hello", () => {
    it("GET /hello", async () => {
        const response = await app.inject({
            method: "GET",
            url: "/hello",
        });

        expect(response.statusCode).toBe(200);
        expect(response.body).toBe("Hello, stranger!");
    });

    it("GET /hello?name=David", async () => {
        const response = await app.inject({
            method: "GET",
            url: "/hello",
            query: { name: "David" },
        });

        expect(response.statusCode).toBe(200);
        expect(response.body).toBe("Hello, David!");
    });

    it("POST /hello", async () => {
        const response = await app.inject({
            method: "POST",
            url: "/hello",
        });

        expect(response.statusCode).toBe(200);
        expect(response.body).toBe("Hello, stranger!");
    });

    it("POST /hello, name='David'", async () => {
        const response = await app.inject({
            method: "POST",
            url: "/hello",
            payload: { name: "David" },
        });

        expect(response.statusCode).toBe(200);
        expect(response.body).toBe("Hello, David!");
    });

    it("POST /hello (body is not a JSON object)", async () => {
        const response = await app.inject({
            method: "POST",
            url: "/hello",
            headers: { "content-type": "application/json; charset=utf-8" },
            payload: "\"David\"",
        });

        expect(response.statusCode).toBe(400);

        const body = response.json<{ validation: { context: string, errors: { instancePath: string, keyword: string }[] } }>();

        expect(body.validation.context).toBe("body");
        expect(body.validation.errors[0]).toMatchObject({
            instancePath: "",
            keyword: "type",
        });
    });

    it("POST /hello (body is an incorrect JSON)", async () => {
        const response = await app.inject({
            method: "POST",
            url: "/hello",
            headers: { "content-type": "application/json; charset=utf-8" },
            payload: "{name: \"David\"}",
        });

        expect(response.statusCode).toBe(400);

        expect(response.json()).toEqual({});
    });

    it("POST /hello, name={} (name is not a string)", async () => {
        const response = await app.inject({
            method: "POST",
            url: "/hello",
            payload: { name: {} },
        });

        expect(response.statusCode).toBe(400);

        const body = response.json<{ validation: { context: string, errors: { instancePath: string, keyword: string }[] } }>();

        expect(body.validation.context).toBe("body");
        expect(body.validation.errors[0]).toMatchObject({
            instancePath: "/name",
            keyword: "type",
        });
    });
});

以上測試中,有個特別需要注意的地方是,雖然我們有用JSON Schema設定請求中傳來的資料必須是一個object,但當請求中的Body不是一個正確的JSON格式時,錯誤處理程序接到的錯誤是SyntaxError,而不是具有validation欄位的FastifyError

錯誤代碼

一個錯誤回應中最好使用錯誤代碼而不是罐頭式的錯誤訊息(所以我們之前並未讓Fastify在處理錯誤時回應Error實體的message欄位),這樣前端在串接後端API的時候才能比較方便且有效率地處理發生錯誤時的後續行為。由於Fastify有內建基於JSON Schema的資料驗證機制,在資料不符合JSON Schema所定義的格式的時候會自動拋出錯誤,所以我們要有個將這些錯誤自動轉換成錯誤代碼的機制才行。

運用TypeScript提供的列舉(Enum),我們可以自行定義列舉的變體名稱,並讓變體的數值由TypeScript自動產生出來,十分方便。在Fastify內建的JSON Schema資料驗證功能所產生的錯誤資訊中,可以找到validationContextschemaPath欄位,我們可以利用它們來組成變體名稱,大致上是將validationContextschemaPath(省略開頭字元#,以及分隔字元/,還有其中的propertiesitems)串接在一起。至於名稱的格式可以用camelcase套件來做正規化,大小寫變化使用帕斯卡命名法(Pascal Case),即:Word1Word2

首先執行以下指令來安裝camelcase套件:

pnpm i camelcase

接著建立src/errors目錄。src/errors目錄用來放置用來放置各種錯誤的列舉和相關程式碼。如果可以的話,建議把所有錯誤的列舉獨立成一個TypeScript函式庫專案,如此後端便能直接與前端共用同一個錯誤代碼列舉,前端就不用擔心會對應到不對的錯誤代碼啦!

然後要建立src/errors/index.ts檔案,內容如下:

import { FastifyRequest, preParsingHookHandler } from "fastify";

export type ErrorCode<T = Record<string, number>> = Readonly<{ errorCode?: T }>;
    
/**
 * Add `errorCodeEnum` to `FastifyRequest` instances (`{errorCode: errorCodeEnum}`) in order to make this route errorCode-able.
 *
 * @param errorCodeEnum should be an enum whose variants' values are all integers
 */
export const createErrorCodePreParsingHook = <T>(errorCodeEnum: T): preParsingHookHandler => {
    return (req: FastifyRequest & { errorCode?: T }, _res, _payload, done) => {
        req.errorCode = errorCodeEnum;
    
        done();
    };
};
    
// APIError
    
type Errors = number[];
    
const DEFAULT_STATUS_CODE = 500;
    
export class APIError extends Error {
    readonly statusCode: number;
    
    readonly errors: Readonly<Errors>;
    
    readonly extra: unknown;
    
    constructor(errors: Errors, extra?: unknown);
    constructor(statusCode: number, errors: Errors, extra?: unknown);
    constructor(statusCodeOrErrors: number | Errors, errors?: unknown, extra?: unknown) {
        if (typeof statusCodeOrErrors === "number") {
            super("API failed.");
    
            this.statusCode = statusCodeOrErrors;
    
            this.errors = errors as Errors;
            this.extra = extra;
        } else {
            super("API failed.");
    
            this.statusCode = DEFAULT_STATUS_CODE;
    
            this.extra = errors;
            this.errors = statusCodeOrErrors;
        }
    
        this.name = "APIError";
    }
}

src/app.fastify.ts檔案修改如下:

import camelCase from "camelcase";
import { FastifyRequest, fastify } from "fastify";

...

import { APIError, ErrorCode } from "./errors/index.js";

...

app.setErrorHandler((error, req: FastifyRequest & ErrorCode, res) => {
    Logger.trace(error);

    if (error instanceof APIError) {
        void res.code(error.statusCode);

        void res.send({
            errors: error.errors,
            extra: error.extra,
        });
    } else {
        const statusCode = error.statusCode ?? 500;

        let validation: {
            context: string,
            errors: FastifySchemaValidationError[],
        } | undefined;

        let errors: number[] | undefined;

        if (statusCode >= 500 && statusCode < 600) {
            Logger.error(error.stack);
        } else if (statusCode === 415) {
            Logger.warn(error.stack);
        } else if (typeof error.validation !== "undefined") {
            // eslint-disable-next-line no-extra-parens
            const context = (error as unknown as { validationContext: string }).validationContext;

            if (typeof req.errorCode !== "undefined") {
                errors = [];

                for (const v of error.validation) {
                    const variantNameTokens = [
                        context, ...v.schemaPath.substring(1).split("/").filter((e) => {
                            return e !== "properties" && e !== "items";
                        }),
                    ];

                    switch (v.keyword) {
                        case "required":
                            variantNameTokens.splice(variantNameTokens.length - 1, 0, v.params.missingProperty as string);
                            break;
                    }

                    const variantName = camelCase(variantNameTokens, { pascalCase: true, preserveConsecutiveUppercase: true });

                    if (Object.prototype.hasOwnProperty.call(req.errorCode, variantName)) {
                        errors.push(req.errorCode[variantName]);
                    } else {
                        Logger.warn(`[ErrorCode] cannot find the variant named ${JSON.stringify(variantName)}`);
                    }
                }
            } else {
                error.validation.forEach((e) => {
                    delete e.message;
                });

                validation = {
                    context,
                    errors: error.validation,
                };
            }
        } else if (error instanceof SyntaxError) {
            if (typeof req.errorCode !== "undefined") {
                errors = [];

                const variantName = "BodyType";

                if (Object.prototype.hasOwnProperty.call(req.errorCode, variantName)) {
                    errors.push(req.errorCode[variantName]);
                } else {
                    Logger.warn(`[ErrorCode] cannot find the variant named ${JSON.stringify(variantName)}`);
                }
            }
        }

        void res.code(statusCode);

        void res.send({
            errors,
            validation,
        });
    }
});

...

以上程式有個特別需要提及的地方是,若有使用錯誤代碼機制且遇到SyntaxError時,會去使用BodyType這個變體名稱。

範例程式

src/errors目錄底下定義錯誤代碼的列舉。

export enum ExampleErrorCode {
    BodyType = 1,
    BodyNameType,

    NameCannotBeJohn = 100,
}

在註冊路由的時候使用createErrorCodePreParsingHook函數透過傳入一個錯誤代碼的列舉來建立出preParsingHook,以便讓錯誤處理程序知道可以用錯誤代碼機制來處理錯誤。我們要修改src/routes/example.route.ts檔案,內容如下:

import { FastifyInstance } from "fastify";

import { ExampleController } from "../controllers/example.controller.js";
import { ExampleErrorCode } from "../errors/example.error.js";
import { createErrorCodePreParsingHook } from "../errors/index.js";
import { HelloSchema } from "../schemas/example.schema.js";

import { Route } from "./route.js";

const ExampleRoute: Route = {
    setRoutes(app: FastifyInstance): void {
        app.get("/hello", {
            preParsing: createErrorCodePreParsingHook(ExampleErrorCode),
            schema: { querystring: HelloSchema },
        }, ExampleController.hello);
        app.post("/hello", {
            preParsing: createErrorCodePreParsingHook(ExampleErrorCode),
            schema: { body: HelloSchema },
        }, ExampleController.helloPost);
    },
};

// eslint-disable-next-line import/no-unused-modules
export default ExampleRoute;

接著加入不跟John打招呼的功能。修改src/routes/example.controller.ts檔案,內容如下:

import { FastifyReply, FastifyRequest } from "fastify";

import { ExampleErrorCode } from "../errors/example.error.js";
import { APIError } from "../errors/index.js";

export const ExampleController = {
    hello: (req: FastifyRequest<{Querystring: { name?: string } }>, res: FastifyReply): void => {
        if (req.query.name === "John") {
            throw new APIError(400, [ExampleErrorCode.NameCannotBeJohn]);
        }

        void res.send(`Hello, ${req.query.name ?? "stranger"}!`);
    },

    helloPost: (req: FastifyRequest<{Body?: {name?: string}}>, res: FastifyReply): void => {
        if (req.body?.name === "John") {
            throw new APIError(400, [ExampleErrorCode.NameCannotBeJohn]);
        }

        void res.send(`Hello, ${req.body?.name ?? "stranger"}!`);
    },
};

修改測試程式。

import app from "../src/app.fastify.js";

import { ExampleErrorCode } from "../src/errors/example.error.js";

describe("say hello", () => {
    it("GET /hello", async () => {
        const response = await app.inject({
            method: "GET",
            url: "/hello",
        });

        expect(response.statusCode).toBe(200);
        expect(response.body).toBe("Hello, stranger!");
    });

    it("GET /hello?name=David", async () => {
        const response = await app.inject({
            method: "GET",
            url: "/hello",
            query: { name: "David" },
        });

        expect(response.statusCode).toBe(200);
        expect(response.body).toBe("Hello, David!");
    });

    it("GET /hello?name=John", async () => {
        const response = await app.inject({
            method: "GET",
            url: "/hello",
            query: { name: "John" },
        });

        expect(response.statusCode).toBe(400);

        expect(response.json<{ errors: number[] }>().errors).toEqual([ExampleErrorCode.NameCannotBeJohn]);
    });

    it("POST /hello", async () => {
        const response = await app.inject({
            method: "POST",
            url: "/hello",
        });

        expect(response.statusCode).toBe(200);
        expect(response.body).toBe("Hello, stranger!");
    });

    it("POST /hello, name='David'", async () => {
        const response = await app.inject({
            method: "POST",
            url: "/hello",
            payload: { name: "David" },
        });

        expect(response.statusCode).toBe(200);
        expect(response.body).toBe("Hello, David!");
    });

    it("POST /hello, name='John' (errorCode = ExampleErrorCode.NameCannotBeJohn)", async () => {
        const response = await app.inject({
            method: "POST",
            url: "/hello",
            payload: { name: "John" },
        });

        expect(response.statusCode).toBe(400);

        expect(response.json<{ errors: number[] }>().errors).toEqual([ExampleErrorCode.NameCannotBeJohn]);
    });

    it("POST /hello (errorCode = ExampleErrorCode.BodyType, body is not a JSON object)", async () => {
        const response = await app.inject({
            method: "POST",
            url: "/hello",
            headers: { "content-type": "application/json; charset=utf-8" },
            payload: "\"David\"",
        });

        expect(response.statusCode).toBe(400);

        expect(response.json<{ errors: number[] }>().errors).toEqual([ExampleErrorCode.BodyType]);
    });

    it("POST /hello (errorCode = ExampleErrorCode.BodyType, body is an incorrect JSON)", async () => {
        const response = await app.inject({
            method: "POST",
            url: "/hello",
            headers: { "content-type": "application/json; charset=utf-8" },
            payload: "{name: \"David\"}",
        });

        expect(response.statusCode).toBe(400);

        expect(response.json<{ errors: number[] }>().errors).toEqual([ExampleErrorCode.BodyType]);
    });

    it("POST /hello, name={} (errorCode = ExampleErrorCode.BodyNameType, name is not a string)", async () => {
        const response = await app.inject({
            method: "POST",
            url: "/hello",
            payload: { name: {} },
        });

        expect(response.statusCode).toBe(400);

        expect(response.json<{ errors: number[] }>().errors).toEqual([ExampleErrorCode.BodyNameType]);
    });
});

插件

依照自己的需求來決定要安裝哪些Fastify的插件。以下介紹幾個基本的插件的安裝方式。

CORS (Cross-Origin Resource Sharing)

用以下指令安裝所需的套件:

pnpm i @fastify/cors

修改src/app.fastify.ts以啟用CORS。如下:

...

import cors from "@fastify/cors";

...

void app.register(cors, {
    maxAge: 3600,
    origin: true,
    credentials: true,
});

...
x-www-form-urlencoed

用以下指令安裝所需的套件:

pnpm i @fastify/formbody

修改src/app.fastify.ts以支援application/x-www-form-urlencoded類型的請求Body。如下:

...

import formbody from "@fastify/formbody";

...

void app.register(formbody);

...
form-data

用以下指令安裝所需的套件:

pnpm i @fastify/multipart

修改src/app.fastify.ts以支援multipart/form-data類型的請求Body。如下:

...

import multipart from "@fastify/multipart";

...

void app.register(multipart);

...

詳細用法可以參考@fastify/multipart的文件說明。筆者覺得這個插件跟JSON Schema沒有整合得很好,還待改進。

靜態檔案

用以下指令安裝所需的套件:

pnpm i @fastify/static

修改src/app.fastify.ts讓Fastify可以提供靜態檔案。如下:

import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";

...

import { fastifyStatic } from "@fastify/static";

...

const __dirname = dirname(fileURLToPath(import.meta.url));

void app.register(fastifyStatic, { root: resolve(__dirname, "../public"), prefix: "/static" });

...

以上設定可以讓GET /static/...的請求去對應到程式專案根目錄下的public目錄中的檔案。

優雅的關機(Graceful Shutdown)

我們通常會希望當Web服務在被停止之前能夠好好地把目前正在處理的請求全都處理完後再停止,以避免髒數據(Dirty Data)的產生。

用以下指令安裝所需的套件:

pnpm i fastify-graceful-shutdown

修改src/app.fastify.ts讓Fastify可以在被停止前等待手上的工作先處理完畢。如下:

...

import fastifyGracefulShutdown from "fastify-graceful-shutdown";

...

void app.register(fastifyGracefulShutdown);

app.after(() => {
    app.gracefulShutdown((signal, next) => {
        Logger.info(`Received ${signal}. Shutting down...`);

        next();
    });
});

...
OpenAPI 文件 (Swagger)

OpenAPI是一種用來定義RESTful API規格的標準,與JSON Schema相容。它是一種經過標準化的文字資料結構,可以使用JSON、YAML等文件格式來撰寫。我們可以利用有支援OpenAPI的工具來自動產生API文件,或者是Web服務的路由程式碼。

Fastify官方提供的@fastify/swagger套件可以在使用Fastify的JSON Schema機制來實作出驗證請求和回應中所帶的資料的功能的同時,也順便產生API文件出來,非常方便!

用以下指令安裝所需的套件:

pnpm i @fastify/swagger swagger-ui-dist fnv1a http-status-codes

雖然Fastify官方有提供@fastify/swagger-ui套件來自動生成Swagger UI的相關路由,但有一些限制存在。所以筆者還是喜歡自己透過swagger-ui-dist套件來撰寫Swagger UI相關的處理程式。fnv1a套件提供了FNV-1a雜湊演算法,可以用來快速計算HTTP快取會用到的ETag。http-status-codes套件則可以使用HTTP狀態碼來查詢對應的錯誤描述。

新增src/app.swagger-spec.ts檔案,內容如下:

export const swaggerSpec: import("@fastify/swagger").FastifyDynamicSwaggerOptions["openapi"] = {
    info: {
        title: "Example",
        description: "Example",
        version: process.env.npm_package_version ?? "0.0.0",
    },
    components: {
        securitySchemes: {
            bearerAuth: {
                type: "http",
                scheme: "bearer",
                description: "Example",
            },
        },
    },
    tags: [
        {
            name: "Example",
            description: "Example",
            externalDocs: { url: "http://example.com" },
        },
    ],
};

上面的程式用來設定我們的Web服務的API規格。如果您還不熟悉OpenAPI的寫法,可以參考這個網頁。正常來說,API端點(endpoint)的規格應定義在OpenAPI的paths欄位,不過我們應該要善用Fastify的JSON Schema機制,將端點的OpenAPI文件寫在JSON Schema中,將它們整合在一起,如此一來端點的請求與回應的資料驗證和用來產生API文件的OpenAPI文件就不需要分兩邊撰寫。API端點的網址前綴(OpenAPI的servers欄位)筆者是用環境變數來控制,所以這些也不會被寫在src/app.swagger-spec.ts檔案中。

再來新增src/app.swagger.ts檔案,內容如下:

import fs from "node:fs";

import { FastifyInstance } from "fastify";

import fnv1aPkg from "fnv1a";

import { swaggerSpec } from "./app.swagger-spec.js";

const fnv1a = fnv1aPkg as unknown as typeof fnv1aPkg.default;

const CACHE_CONTROL_MAX_AGE = 86400;
const ENV_PREFIX = "SWAGGER_SERVER_";

const DOCS_PATH = process.env.DOCS_PATH ?? "/docs";

export const swaggerOptions: import("@fastify/swagger").SwaggerOptions = { openapi: swaggerSpec };

export const afterRegisterRoutes = (app: FastifyInstance) => {
    if (typeof app.swagger === "function") {
        const lastDocsPath = DOCS_PATH.substring(DOCS_PATH.lastIndexOf("/"), DOCS_PATH.length);

        const data = {
            swaggerSpecEtag: "",
            swaggerUIDistEtag: "",
            swaggerSpecJSON: "",
        };

        app.addHook("onReady", () => {
            const swaggerSpec = app.swagger();

            // add servers from environment variables
            const servers = [];

            for (const variableName of Object.keys(process.env)) {
                if (variableName.startsWith(ENV_PREFIX)) {
                    const order = parseInt(variableName.substring(ENV_PREFIX.length));

                    if (isNaN(order)) {
                        continue;
                    }

                    servers.push({
                    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
                        url: process.env[variableName]!,
                        order,
                    });
                }
            }

            servers.sort((a, b) => {
                return a.order - b.order;
            });

            // eslint-disable-next-line no-extra-parens
            (swaggerSpec as (typeof swaggerSpec & { servers: typeof servers })).servers = servers;

            data.swaggerSpecJSON = JSON.stringify(swaggerSpec).replace(/<\/(script)>/gi, "<\\/$1>");
            data.swaggerSpecEtag = `W/"${fnv1a(data.swaggerSpecJSON)}"`;
            data.swaggerUIDistEtag = `W/"${fnv1a(process.env.npm_package_dependencies_swagger_ui_dist ?? "")}"`;
        });

        void app.register((app, _opts, done) => {
            const schemaHide = { schema: { hide: true } };

            app.get(`${DOCS_PATH}`, schemaHide, (req, res) => {
                if (req.url.endsWith("/")) {
                    void res.redirect("./static/index.html");
                } else {
                    void res.redirect(`.${lastDocsPath}/static/index.html`);
                }
            });

            app.get(`${DOCS_PATH}/static`, schemaHide, (req, res) => {
                if (req.url.endsWith("/")) {
                    void res.redirect("./index.html");
                } else {
                    void res.redirect("./static/index.html");
                }
            });

            app.get(`${DOCS_PATH}/json`, schemaHide, (req, res) => {
                if (req.headers["if-none-match"] === data.swaggerSpecEtag) {
                    return void res.code(304).send();
                }

                void res.code(200)
                    .header("content-type", "application/json; charset=utf-8")
                    .header("etag", data.swaggerSpecEtag)
                    .send(swaggerSpec);
            });

            app.get(`${DOCS_PATH}/static/index.html`, schemaHide, (req, res) => {
                if (req.url.endsWith("/")) {
                    return void res.redirect(`${DOCS_PATH}/static/index.html`);
                }

                if (req.headers["if-none-match"] === data.swaggerSpecEtag) {
                    return void res.code(304).send();
                }

                void res.code(200)
                    .header("content-type", "text/html; charset=utf-8")
                    .header("etag", data.swaggerSpecEtag)
                    .send(`<!DOCTYPE html>
                  <html>
                    <head>
                      <meta charset="UTF-8">
                      <title>API Docs</title>
                      <link rel="stylesheet" type="text/css" href="./swagger-ui.css" />
                    </head>
                  
                    <body>
                      <div id="swagger-ui"></div>
                  
                      <script src="./swagger-ui-bundle.js"></script>
                      <script>
                        const swaggerSpec = ${data.swaggerSpecJSON};
                        const docPathIndex = window.location.href.indexOf('${DOCS_PATH}');
                        if (docPathIndex) {
                          if (!swaggerSpec.servers) {
                            swaggerSpec.servers = [];
                          }
                          const prefix = window.location.href.substring(0, docPathIndex);
                          swaggerSpec.servers = [{
                            url: prefix,
                            description: 'The server of this API document.'
                          }].concat(swaggerSpec.servers);
                        }
                        window.onload = function() {
                          window.ui = SwaggerUIBundle({
                            spec: swaggerSpec,
                            dom_id: '#swagger-ui',
                            deepLinking: true,
                            withCredentials: true,
                          })
                        };
                      </script>
                    </body>
                  </html>`);
            });


            app.get(`${DOCS_PATH}/static/swagger-ui-bundle.js`, schemaHide, (req, res) => {
                if (req.headers["if-none-match"] === data.swaggerUIDistEtag) {
                    return void res.code(304).send();
                }

                const file = fs.createReadStream("./node_modules/swagger-ui-dist/swagger-ui-bundle.js");

                void res.code(200)
                    .header("content-type", "application/javascript;charset=R-8")
                    .header("cache-control", `public, max-age=${CACHE_CONTROL_MAX_AGE}`)
                    .header("etag", data.swaggerUIDistEtag)
                    .send(file);
            });

            app.get(`${DOCS_PATH}/static/swagger-ui-bundle.js.map`, schemaHide, (req, res) => {
                if (req.headers["if-none-match"] === data.swaggerUIDistEtag) {
                    return void res.code(304).send();
                }

                const file = fs.createReadStream("./node_modules/swagger-ui-dist/swagger-ui-bundle.js.map");

                void res.code(200)
                    .header("content-type", "application/json; charset=utf-8")
                    .header("cache-control", `public, max-age=${CACHE_CONTROL_MAX_AGE}`)
                    .header("etag", data.swaggerUIDistEtag)
                    .send(file);
            });

            app.get(`${DOCS_PATH}/static/swagger-ui.css`, schemaHide, (req, res) => {
                if (req.headers["if-none-match"] === data.swaggerUIDistEtag) {
                    return void res.code(304).send();
                }

                const file = fs.createReadStream("./node_modules/swagger-ui-dist/swagger-ui.css");

                void res.code(200)
                    .header("content-type", "text/css; charset=utf-8")
                    .header("cache-control", `public, max-age=${CACHE_CONTROL_MAX_AGE}`)
                    .header("etag", data.swaggerUIDistEtag)
                    .send(file);
            });

            app.get(`${DOCS_PATH}/static/swagger-ui.css.map`, schemaHide, (req, res) => {
                if (req.headers["if-none-match"] === data.swaggerUIDistEtag) {
                    return void res.code(304).send();
                }

                const file = fs.createReadStream("./node_modules/swagger-ui-dist/swagger-ui.css.map");

                void res.code(200)
                    .header("content-type", "application/json; charset=utf-8")
                    .header("cache-control", `public, max-age=${CACHE_CONTROL_MAX_AGE}`)
                    .header("etag", data.swaggerUIDistEtag)
                    .send(file);
            });

            done();
        });
    }
};

以上程式能夠讓Fastify利用Swagger UI來產生API文件,並且可以使用兩種環境變數來控制。第一種環境變數是以SWAGGER_SERVER_為前綴再接上一個整數的環境變數名稱,這種環境變數用來設定OpenAPI規格中的servers欄位(Swagger UI本身所在的網址前綴因為我們有寫程式讓它自動使用網頁的JavaScript程式來加入,所以不需寫進來),整數數字愈小排得愈前面。第二種是DOCS_PATH環境變數,用來設定Swagger UI要使用的路由路徑。

接著修改src/app.fastify.ts檔案,修改方式如下:

...

import { fastifySwagger } from "@fastify/swagger";

...

import { swaggerOptions } from "./app.swagger.js";

...

void app.register(fastifySwagger, swaggerOptions);

...

await registerRoutes(app);

export default app;

以上有個地方要注意,registerRoutes函數必須要在Fastify實體註冊了@fastify/swagger插件之後執行。

然後修改src/app.routes.ts檔案,修改方式如下:

...

import { afterRegisterRoutes } from "./app.swagger.js";

...

    app.after(() => {
        afterRegisterRoutes(app);
    });
};

以上有個地方要注意,app.after那段程式碼必須要在讀取完所有的xxx.route.ts檔案之後才執行。

至此Swagger UI就可以使用了。不過我們還是可以再新增一些讓程式開發起來更方便的程式碼。

新增src/schemas/index.ts檔案,內容如下:

import { getReasonPhrase } from "http-status-codes";

const _errorResponse = {
    type: "object",
    description: "The default error response",
    properties: {},
};

const _validation = {
    type: "object",
    required: [
        "context",
        "errors",
    ],
    properties: {
        context: {
            type: "string",
            enum: [
                "body",
                "params",
                "querystring",
                "headers",
            ],
        },
        errors: {
            type: "array",
            items: {
                type: "object",
                required: [
                    "instancePath",
                    "schemaPath",
                    "keyword",
                    "params",
                ],
                properties: {
                    instancePath: { type: "string" },
                    schemaPath: { type: "string" },
                    keyword: { type: "string" },
                    params: {
                        type: "object",
                        additionalProperties: true,
                    },
                },
            },
            minItems: 1,
        },
    },
};

const _errorCode = {
    type: "array",
    items: { type: "integer" },
};

// eslint-disable-next-line import/no-unused-modules
export type ErrorResponse = typeof _errorResponse & {
    properties: {
        validation?: typeof _validation,
        errors?: typeof _errorCode & { description?: string },
        extra?: unknown,
    }
};

/**
 * @param statusCode HTTP status code
 * @param description the description of this response
 * @param errorCode set to `true` or any non-empty strings (the description of the error code) to use error code
 * @param extraTypes set to `{ type: ... }` or `{ anyOf: [...] }` to include extra data in the response
 */
export const errorResponse = (statusCode?: number, description?: string, errorCode?: boolean | string, extraTypes?: unknown) => {
    const res = structuredClone(_errorResponse) as ErrorResponse;

    if (typeof statusCode !== "undefined") {
        if (statusCode === 400 && !errorCode) {
            res.properties.validation = structuredClone(_validation);
        }

        if (typeof description !== "undefined") {
            res.description = description;
        } else {
            try {
                const reasonPhrase = getReasonPhrase(statusCode);

                res.description = reasonPhrase;
            } catch (err) {
                // do nothing
            }
        }
    } else if (typeof description !== "undefined") {
        res.description = description;
    }

    if (errorCode) {
        if (typeof extraTypes !== "undefined") {
            res.properties.extra = extraTypes;
        }

        res.properties.errors = structuredClone(_errorCode);

        if (errorCode !== true) {
            res.properties.errors.description = errorCode;
        }
    }

    return res;
};

以上模組提供errorResponse函數,可以快速產生端點回應的OpenAPI文件,實際用法等等會介紹。

再來要修改src/errors/index.ts檔案,如下:

...

/**
 * Quickly generate Markdown table text from an `errorCodeEnum` and an `errorCodeVariantsDescriptions` record object.
 *
 * @param errorCodeEnum should be an enum whose variants' values are all integers
 * @param errorCodeVariantsDescriptions a string-string record object
 */
export const generateErrorCodeVariantsDescriptions = (errorCodeEnum: unknown, errorCodeVariantsDescriptions: Record<string, string>) => {
    const errorCodeEnumR = errorCodeEnum as Record<string, string | number>;

    const variants = [];

    for (const key of Object.keys(errorCodeEnumR)) {
        const i = parseInt(key);

        if (isNaN(i)) {
            variants.push({
                name: key,
                code: errorCodeEnumR[key] as number,
            });
        }
    }

    variants.sort((a, b) => {
        return a.code - b.code;
    });

    let markdown = "";

    for (const { name, code } of variants) {
        let description = name;

        if (Object.prototype.hasOwnProperty.call(errorCodeVariantsDescriptions, name)) {
            description = errorCodeVariantsDescriptions[name].replaceAll("|", "&#124;");
        }
        
        markdown += `| ${code} | ${description} |\n`;
    }

    return markdown.trimEnd();
};

...

以上的generateErrorCodeVariantsDescriptions函數可以用來快速產生錯誤代碼的說明列表,實際用法請參考以下範例。

範例程式修改
import { DeepReadonly } from "ts-essentials";

const helloSchema = {
    type: "object",
    nullable: true,
    properties: {
        name: {
            type: "string",
            examples: ["John"],
            description: "The name that you want to greet",
        },
    },
};

export const HelloSchema: DeepReadonly<typeof helloSchema> = helloSchema;

如上,替JSON Schema添加examplesdescription等更多讓人理解這到底在幹啥的資訊。

import { generateErrorCodeVariantsDescriptions } from "./index.js";

export enum ExampleErrorCode {
    BodyType = 1,
    BodyNameType,

    NameCannotBeJohn = 100,
}

const exampleErrorCodeVariantsDescriptions = {
    BodyType: "The format of body in the HTTP request is incorrect",
    BodyNameType: "The type of the field `name` is incorrect",
    NameCannotBeJohn: "The value of the field `name` cannot be `John`",
};

export const exampleErrorCodeDescription = `Error code list:

| Code | Description |
| ---- | ----------- |
${generateErrorCodeVariantsDescriptions(ExampleErrorCode, exampleErrorCodeVariantsDescriptions)}
`;

如上,替Fastify加入了OpenAPI支援之後,我們應該要替每個錯誤代碼列舉新增描述。而描述可以用如上的方式,藉由新建一個存放著每個錯誤代碼的說明的大括號物件和generateErrorCodeVariantsDescriptions函數來快速產生。

import { FastifyInstance } from "fastify";

import { ExampleController } from "../controllers/example.controller.js";
import { ExampleErrorCode, exampleErrorCodeDescription } from "../errors/example.error.js";
import { createErrorCodePreParsingHook } from "../errors/index.js";

import { HelloSchema } from "../schemas/example.schema.js";
import { errorResponse } from "../schemas/index.js";

import { Route } from "./route.js";

const ExampleRoute: Route = {
    setRoutes(app: FastifyInstance): void {
        app.get("/hello", {
            preParsing: createErrorCodePreParsingHook(ExampleErrorCode),
            schema: {
                tags: ["Example"],
                description: "Say hello to a person",
                summary: "Hello!",
                produces: ["application/json", "text/plain"],
                querystring: HelloSchema,
                response: {
                    200: { type: "string", description: "OK" },
                    400: errorResponse(400, undefined, exampleErrorCodeDescription),
                },
            },
        }, ExampleController.hello);

        app.post("/hello", {
            preParsing: createErrorCodePreParsingHook(ExampleErrorCode),
            schema: {
                tags: ["Example"],
                description: "Say hello to a person",
                summary: "Hello!",
                produces: ["application/json", "text/plain"],
                body: HelloSchema,
                response: {
                    200: { type: "string", description: "OK" },
                    400: errorResponse(400, undefined, exampleErrorCodeDescription),
                },
            },
        }, ExampleController.helloPost);
    },
};

// eslint-disable-next-line import/no-unused-modules
export default ExampleRoute;

如上,我們可以利用剛才加入的程式碼來簡潔有力地撰寫支援錯誤代碼機制的JSON Schema!

Docker

我們可以讓我們的Web服務運行在Docker容器中,以隔離環境、方便部署。

如果您還不熟悉Docker,請先參考這篇文章:

首先在程式專案根目錄中建立.dockerignore檔案,內容如下:

*

!/src
!/tests
!/.eslintrc
!/pnpm-lock.yaml
!/*.json

透過以上的.dockerignore檔案,我們只會把部份的檔案納進Docker的build context中,以加快Docker映像的建立速度。

筆者將Docker的設定分為開發(Development)環境和生產(Production)環境,以下會分別介紹要怎麼加入。

開發環境上的 Docker

我們可以建立出一種映像,它並沒有包含我們Web服務的JavaScript程式碼以及TypeScript程式碼,而是提供一個較完整的執行環境,讓我們可以將srctests目錄掛載進容器內進行編譯執行與測試的動作。這樣在Host上我們只需要VS Code和Docker就可以進行程式開發。

開發環境用的Dockerfile筆者習慣將其命名為Dockerfile.dev,內容如下:

FROM rust:slim AS builder

WORKDIR /build

RUN cargo install wait-service --features json \
    && cp ${CARGO_HOME}/bin/wait-service /build


FROM node:20-slim

RUN npm i -g pnpm

EXPOSE 3000 9229 9230

WORKDIR /app

RUN chown node:node /app

COPY --chown=node:node --from=builder /build/wait-service /app/
COPY --chown=node:node pnpm-lock.yaml .eslintrc *.json /app/

USER node

RUN pnpm i --frozen-lockfile && pnpm store prune

CMD npm run start:dev

以上的Dockerfile中,使用到wait-service這個工具,介紹可以參考這篇文章:

如果您確定您的Web服務不會用TCP或是UDS再去連其它服務,就無需使用wait-service,可以將它從Dockerfile中拿掉。

開發環境上的Docker映像是基於輕量(slim)的Debian映像,本身即保有一些方便的指令工具能用,如果有缺也可以再於Dockerfile中使用RUN命令透過apt指令來安裝。映像內會預先安裝好完整的node_modules(包含devDependencies)。

以下指令可以建立開發用的Docker映像:

docker build -t $(node -p "require('./package.json').name"):dev -f Dockerfile.dev .

以下指令可以啟動開發用的Docker容器:

docker run -t -d -v "$(pwd)/src:/app/src" -v "$(pwd)/tests:/app/tests" -p 9229:9229 -p 9230:9230 -P --name $(node -p "require('./package.json').name")-dev $(node -p "require('./package.json').name"):dev && docker port $(node -p "require('./package.json').name")-dev

以上指令會用同號的Host連接埠映射容器中inspect的連接埠,而容器中的Web服務監聽的TCP連接埠3000則讓Docker自行決定要用Host的哪個連接埠來映射。

以下指令可以顯示開發用的Docker容器中的Web服務所監聽的TCP連接埠是Host的哪個連接埠:

docker port $(node -p "require('./package.json').name")-dev 3000 | cut -f 2 -d ':' | tr -d '[:space:]'

以下指令可以顯示開發用的Docker容器的日誌:

docker logs -f $(node -p "require('./package.json').name")-dev

以下指令可以停止開發用的Docker容器:

docker stop $(node -p "require('./package.json').name")-dev

以下指令可以啟動停止中開發用的Docker容器:

docker start $(node -p "require('./package.json').name")-dev

以下指令可以(強制)移除開發用的Docker容器:

docker rm -f $(node -p "require('./package.json').name")-dev

以下指令可以在開發用的Docker容器中跑測試:

docker exec -t $(node -p "require('./package.json').name")-dev npm run test

以下指令也可以在開發用的Docker容器中跑測試,但會先等待Debugger的接入(稍候會說明如何設定VS Code):

docker exec -t $(node -p "require('./package.json').name")-dev npm run test:inspect-brk
生產環境上的 Docker

我們可以建立出一種映像,它僅包含Node.js執行環境以及我們用TypeScript編譯出來的JavaScript程式碼和其相依的node_modules,讓我們的Web服務能夠輕巧地運行在Docker容器中。

生產環境用的Dockerfile筆者習慣直接將其命名為Dockerfile,內容如下:

FROM rust:slim AS builder

WORKDIR /build

RUN apt update && apt install -y make musl-tools musl-dev \
    && rustup target add x86_64-unknown-linux-musl

RUN cargo install --target x86_64-unknown-linux-musl wait-service --features json \
    && cp ${CARGO_HOME}/bin/wait-service /build


FROM node:20-alpine AS node-builder

RUN npm i -g pnpm

WORKDIR /build

RUN chown node:node /build

COPY --chown=node:node package.json pnpm-lock.yaml tsconfig*.json /build/
COPY --chown=node:node src /build/src/

USER node

RUN pnpm i --frozen-lockfile && npm run build && rm -rf node_modules && pnpm i --frozen-lockfile --production


FROM node:20-alpine

EXPOSE 3000

WORKDIR /app

RUN chown node:node /app

COPY --chown=node:node --from=builder /build/wait-service /app/
COPY --chown=node:node --from=node-builder /build/package*.json /app/
COPY --chown=node:node --from=node-builder /build/dist /app/dist/
COPY --chown=node:node --from=node-builder /build/node_modules /app/node_modules/

USER node

CMD npm start

以上的Dockerfile中,同樣也使用到wait-service這個工具,如果您不需要也可以自行移除。生產環境上的Docker映像是基於alpine映像,非常輕巧。

以下指令可以建立生產用的Docker映像:

docker build -t $(node -p "require('./package.json').name") .

以下指令可以啟動生產用的Docker容器:

docker run -t -d --restart always -P --name $(node -p "require('./package.json').name") $(node -p "require('./package.json').name") && docker port $(node -p "require('./package.json').name")

以下指令可以顯示生產用的Docker容器中的Web服務所監聽的TCP連接埠是Host的哪個連接埠:

docker port $(node -p "require('./package.json').name") 3000 | cut -f 2 -d ':' | tr -d '[:space:]'

以下指令可以顯示生產用的Docker容器的日誌:

docker logs -f $(node -p "require('./package.json').name")

以下指令可以停止生產用的Docker容器:

docker stop $(node -p "require('./package.json').name")

以下指令可以啟動停止中開發用的Docker容器:

docker start $(node -p "require('./package.json').name")

以下指令可以(強制)移除開發用的Docker容器:

docker rm -f $(node -p "require('./package.json').name")
VS Code 設定

.vscode/launch.json檔案中加入以下設定,讓VS Code的Debugger可以附加Docker容器的Node.js程式。

{
    ...

    "configurations": [
        ...

        {
            "type": "node",
            "name": "Docker:inspect",
            "request": "attach",
            "port": 9229,
            "remoteRoot": "/app",
            "restart": true
        },
        {
            "type": "node",
            "name": "Docker:inspect-brk",
            "request": "attach",
            "port": 9230,
            "remoteRoot": "/app"
        }
    ]
}

Docker Compose

替我們的Fastify應用程式加入Docker支援之後,還可以再加入Docker Compose,以方便管理。

如果您還不熟悉Docker Compose,請先參考這篇文章:

筆者同樣將Docker Compose的設定分為開發(Development)環境和生產(Production)環境,以下會分別介紹要怎麼加入。

開發環境上的 Docker Compose

由於在開發環境上會比較常去透過docker-compose指令來執行或停止服務,所以開發環境用的Docker Compose設定檔筆者習慣將其命名為預設的docker-compose.yml,內容如下:

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile.dev
    image: example:dev
	  tty: true
    # environment:
    #   EXAMPLE: example
    volumes:
      - ./src:/app/src
      - ./tests:/app/tests
    ports:
      - "3000:3000"
      # - "9229:9229"
      # - "9230:9230"
    # networks:
    #   - example
    # depends_on:
    #   - mongo
    # command: ./wait-service --tcp mongo.example:27017 -- npm run start:dev

# networks:
#   example:
#     name: example

使用以下指令就可以啟動我們開發用的Web服務:

docker-compose up --build
生產環境上的 Docker Compose

生產環境用的Docker Compose設定檔筆者習慣將其命名為docker-compose.prod.yml,內容如下:

services:
  app:
    restart: always
    build:
      context: .
      dockerfile: Dockerfile
    image: example
    tty: true
    # environment:
    #   EXAMPLE: example
    ports:
      - "3000:3000"
    # networks:
    #   - example
    # depends_on:
    #   - mongo
    # command: ./wait-service --tcp mongo.example:27017 -- npm start

# networks:
#   example:
#     name: example

使用以下指令就可以啟動我們生產用的Web服務:

docker-compose -f docker-compose.prod.yml up --build