» Node.js:使用Express构建REST API » 2. 开发 » 2.5 4层架构

4层架构

目前,你的代码可以跑起来。但是,细观之下,不够“洁净”。

不够“洁净”

如下是代码不够洁净的几个点:

// 数据库实例
let db: sqlite3.Database;
  • 不应该显式地使用全局变量来承载数据库实例并到处使用。
function initDB() {
  db = new sqlite3.Database("./test.db", (err) => {
    if (err) {
      console.error("Error opening database:", err.message);
    } else {
      console.log("Connected to the database.");
      db.exec(
        `CREATE TABLE IF NOT EXISTS books (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            title TEXT NOT NULL,
            author TEXT NOT NULL,
            published_at TEXT NOT NULL,
            description TEXT NOT NULL,
            isbn TEXT NOT NULL,
            total_pages INTEGER NOT NULL,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
            updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
          )`,
        (err) => {
          if (err) {
            console.error("Error opening database:", err.message);
          } else {
            console.log("Successfully initialized tables.");
          }
        }
      );
    }
  });
}
  • 业务代码中去初始化数据库不是最佳实践。数据库操作属于“基础架构”,而不是“业务”。它们属于不同的关注点,你应该将其分开到不同地方。
// GET a single book by ID
app.get("/books/:id", (req: Request, res: Response) => {
  const id = parseInt(req.params.id);
  db.get("SELECT * FROM books WHERE id = ?", [id], (err, row) => {
    if (err) {
      console.error("Error getting book:", err.message);
      res.status(500).json({ error: "Internal Server Error" });
    } else if (row) {
      res.json(row);
    } else {
      res.status(404).json({ message: "Book not found" });
    }
  });
});
  • 不推荐在业务代码中直接依赖三方库。db.get 是来自 sqlite3 三方包的方法。 要不然将来想切换到其他数据库框架库的话,业务改动成本极高。

关注点分离

Clean Arch 来自 https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

整洁架构(The Clean Architecture) 是 Robert C. Martin 在他的书《整洁架构:软件结构与设计的工匠指南》中引入的软件架构模式。 它强调了软件系统内的关注点分离,其主要目标是使系统在其生命周期内更易于维护、可扩展和可测试。

4层架构在细节上有所不同,但与整洁架构类似。它们都有相同的目标,即关注点分离。它们都通过将软件划分为层来实现这种分离。

4 layers

  1. Adapter 适配器层:负责路由和适配框架、协议和前端显示(Web、移动等)。

  2. Application 应用层:主要负责获取输入、组装上下文、参数验证,调用领域层进行业务处理。该层是开放式的,应用层可以绕过领域层并直接访问基础设施层。

  3. Domain 领域层:封装核心业务逻辑,并通过领域服务和领域实体的方法向应用层提供业务实体和业务逻辑操作。领域是应用程序的核心,不依赖于任何其他层

  4. Infrastructure 基础设施层:主要处理诸如数据库上的CRUD操作、搜索引擎、文件系统、用于分布式服务的RPC等技术细节。外部依赖项需要通过此处的网关进行转换,然后才能被上面的应用层和领域层使用。

重构

让我们基于4层架构来重构。

新的目录结构如下所示:

projects/lr_rest_books_node
├── LICENSE
├── README.md
├── package-lock.json
├── package.json
├── src
│   ├── adapter
│   │   ├── index.ts
│   │   └── router.ts
│   ├── application
│   │   ├── executor
│   │   │   ├── book_operator.ts
│   │   │   └── index.ts
│   │   ├── index.ts
│   │   └── wire_helper.ts
│   ├── domain
│   │   ├── gateway
│   │   │   ├── book_manager.ts
│   │   │   └── index.ts
│   │   └── model
│   │       ├── book.ts
│   │       └── index.ts
│   ├── infrastructure
│   │   ├── config
│   │   │   ├── config.ts
│   │   │   └── index.ts
│   │   └── database
│   │       ├── index.ts
│   │       └── sqlite.ts
│   └── main.ts
├── test.db
└── tsconfig.json

移动 model/book.tsdomain/model/book.ts

export interface Book {
  id: number;
  title: string;
  author: string;
  published_at: string;
  description: string;
  isbn: string;
  total_pages: number;
  created_at: Date;
  updated_at: Date;
}

domain 目录存放核心业务规则。非业务代码远离该目录。

创建 domain/model/index.ts

export { Book } from "./book";

该行使得别处的 import 更容易。你可以使用 import { Book } from "@/domain/model" 替代 import { Book } from "@/domain/model/book"。 你可以在 src/ 下所有子目录中添加 index.ts

@/... 是路径别名。它可以帮你省去各种相对路径(比如:../../ 甚至 ../../../)的麻烦。

修改 tsconfig.json 来实现别名:

@@ -4,7 +4,11 @@
     "module": "CommonJS",
     "outDir": "./dist",
     "strict": true,
-    "esModuleInterop": true
+    "esModuleInterop": true,
+    "baseUrl": "./",
+    "paths": {
+      "@/*": ["src/*"]
+    }
   },
   "include": ["src/**/*.ts"],

想要 tsc 编译之后也能正常工作的话,你需要安装 tsc-alias

npm i tsc-alias

了解更多:https://stackoverflow.com/a/67227416

更新 package.json 中 scripts:

@@ -2,11 +2,11 @@
   "name": "lr_rest_books_node",
   "version": "1.0.0",
   "description": "RESTful API implemented with Express in Node.js.",
-  "main": "app.js",
+  "main": "main.js",
   "scripts": {
-    "dev": "ts-node src/app.ts",
-    "build": "tsc",
-    "serve": "node dist/app.js"
+    "dev": "npm run build && npm run serve",
+    "build": "tsc && tsc-alias",
+    "serve": "node dist/main.js"
   },

创建 domain/gateway/book_manager.ts

import { Book } from "@/domain/model";

export interface BookManager {
  createBook(b: Book): Promise<number>;
  updateBook(id: number, b: Book): Promise<void>;
  deleteBook(id: number): Promise<void>;
  getBook(id: number): Promise<Book | null>;
  getBooks(): Promise<Book[]>;
}

BookManager 接口定义领域实体 Book 的业务能力。 此处只是接口,具体实现交由基础架构层去完成。 另外,这些方法使得应用层可以调用它执行业务逻辑。

创建 infrastructrue/database/sqlite.ts

import sqlite3 from "sqlite3";

import { Book } from "@/domain/model/book";
import { BookManager } from "@/domain/gateway/book_manager";

export class SQLitePersistence implements BookManager {
  private db: sqlite3.Database;

  constructor(dbFilePath: string) {
    this.db = new sqlite3.Database(dbFilePath, (err) => {
      if (err) {
        console.error("Error opening database:", err.message);
      } else {
        console.log("Connected to the database.");
        this.db.exec(
          `CREATE TABLE IF NOT EXISTS books (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                title TEXT NOT NULL,
                author TEXT NOT NULL,
                published_at TEXT NOT NULL,
                description TEXT NOT NULL,
                isbn TEXT NOT NULL,
                total_pages INTEGER NOT NULL,
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
              )`,
          (err) => {
            if (err) {
              console.error("Error opening database:", err.message);
            } else {
              console.log("Successfully initialized tables.");
            }
          }
        );
      }
    });
  }

  async createBook(b: Book): Promise<number> {
    return new Promise<number>((resolve, reject) => {
      const { title, author, published_at, description, isbn, total_pages } = b;
      this.db.run(
        `INSERT INTO books (title, author, published_at, description, isbn, total_pages) VALUES (?, ?, ?, ?, ?, ?)`,
        [title, author, published_at, description, isbn, total_pages],
        function (err) {
          if (err) {
            console.error("Error creating book:", err.message);
            reject(err);
          } else {
            resolve(this.lastID);
          }
        }
      );
    });
  }

  async updateBook(id: number, b: Book): Promise<void> {
    return new Promise<void>((resolve, reject) => {
      const { title, author, published_at, description, isbn, total_pages } = b;
      this.db.run(
        `UPDATE books SET title = ?, author = ?, published_at = ?, description = ?, isbn = ?, total_pages = ? WHERE id = ?`,
        [title, author, published_at, description, isbn, total_pages, id],
        (err) => {
          if (err) {
            console.error("Error updating book:", err.message);
            reject(err);
          } else {
            resolve();
          }
        }
      );
    });
  }

  async deleteBook(id: number): Promise<void> {
    return new Promise<void>((resolve, reject) => {
      this.db.run("DELETE FROM books WHERE id = ?", [id], (err) => {
        if (err) {
          console.error("Error deleting book:", err.message);
          reject(err);
        } else {
          resolve();
        }
      });
    });
  }

  async getBook(id: number): Promise<Book | null> {
    return new Promise<Book | null>((resolve, reject) => {
      this.db.get("SELECT * FROM books WHERE id = ?", [id], (err, row) => {
        if (err) {
          console.error("Error getting book:", err.message);
          reject(err);
        } else if (row) {
          resolve(row as Book);
        } else {
          resolve(null);
        }
      });
    });
  }

  async getBooks(): Promise<Book[]> {
    return new Promise<Book[]>((resolve, reject) => {
      this.db.all("SELECT * FROM books", (err, rows) => {
        if (err) {
          console.error("Error getting books:", err.message);
          reject(err);
        } else {
          resolve(rows as Book[]);
        }
      });
    });
  }

  close(): void {
    this.db.close();
  }
}

如你所见,类 SQLitePersistence 实现了接口 BookManager 中所有方法。

创建 infrastructrue/config/config.ts

interface DBConfig {
  fileName: string;
}

interface ApplicationConfig {
  port: number;
}

export interface Config {
  app: ApplicationConfig;
  db: DBConfig;
}

接口 Config 将硬编码的配置项拎出。一般而言,配置“拎出来”都是好的工程实践。

创建 application/executor/book_operator.ts

import { BookManager } from "@/domain/gateway";
import { Book } from "@/domain/model";

export class BookOperator {
  private bookManager: BookManager;

  constructor(b: BookManager) {
    this.bookManager = b;
  }

  async createBook(b: Book): Promise<Book> {
    const id = await this.bookManager.createBook(b);
    b.id = id;
    return b;
  }

  async getBook(id: number): Promise<Book | null> {
    return await this.bookManager.getBook(id);
  }

  async getBooks(): Promise<Book[]> {
    return await this.bookManager.getBooks();
  }

  async updateBook(id: number, b: Book): Promise<Book> {
    await this.bookManager.updateBook(id, b);
    return b;
  }

  async deleteBook(id: number): Promise<void> {
    await this.bookManager.deleteBook(id);
  }
}

应用层封装所有底层的逻辑,并提供接口给顶层的适配层

创建 application/wire_helper.ts

import { SQLitePersistence } from "@/infrastructure/database";
import { Config } from "@/infrastructure/config";
import { BookManager } from "@/domain/gateway";

// WireHelper is the helper for dependency injection
export class WireHelper {
  private persistence: SQLitePersistence;

  constructor(c: Config) {
    this.persistence = new SQLitePersistence(c.db.fileName);
  }

  bookManager(): BookManager {
    return this.persistence;
  }
}

WireHelper 帮助实现 DI(依赖注入)。 如果想要更全面的 DI 支持,考虑使用 InversifyJS

有了这些准备之后,router 终于可以“整洁地”完成工作了。 它不用感知底层数据库的存在。此刻,关注点被分离了。

创建 adapter/router.ts

import express, { Request, Response } from "express";

import { Book } from "@/domain/model";
import { BookOperator } from "@/application/executor";
import { WireHelper } from "@/application";

class RestHandler {
  private bookOperator: BookOperator;

  constructor(bookOperator: BookOperator) {
    this.bookOperator = bookOperator;
  }

  // Get all books
  public async getBooks(req: Request, res: Response): Promise<void> {
    try {
      const books = await this.bookOperator.getBooks();
      res.status(200).json(books);
    } catch (err) {
      console.error(`Failed to get books: ${err}`);
      res.status(404).json({ error: "Failed to get books" });
    }
  }

  // Get single book
  public async getBook(req: Request, res: Response): Promise<void> {
    const id = parseInt(req.params.id);
    if (isNaN(id)) {
      res.status(404).json({ error: "Invalid id" });
      return;
    }
    try {
      const book = await this.bookOperator.getBook(id);
      res.status(200).json(book);
    } catch (err) {
      console.error(`Failed to get the book with ${id}: ${err}`);
      res.status(404).json({ error: "Failed to get the book" });
    }
  }

  // Create a new book
  public async createBook(req: Request, res: Response): Promise<void> {
    try {
      const book = await this.bookOperator.createBook(req.body as Book);
      res.status(201).json(book);
    } catch (err) {
      console.error(`Failed to create: ${err}`);
      res.status(404).json({ error: "Failed to create" });
    }
  }

  // Update an existing book
  public async updateBook(req: Request, res: Response): Promise<void> {
    const id = parseInt(req.params.id);
    if (isNaN(id)) {
      res.status(404).json({ error: "Invalid id" });
      return;
    }
    try {
      const book = await this.bookOperator.updateBook(id, req.body as Book);
      res.status(200).json(book);
    } catch (err) {
      console.error(`Failed to update: ${err}`);
      res.status(404).json({ error: "Failed to update" });
    }
  }

  // Delete an existing book
  public async deleteBook(req: Request, res: Response): Promise<void> {
    const id = parseInt(req.params.id);
    if (isNaN(id)) {
      res.status(404).json({ error: "Invalid id" });
      return;
    }
    try {
      await this.bookOperator.deleteBook(id);
      res.status(204).end();
    } catch (err) {
      console.error(`Failed to delete: ${err}`);
      res.status(404).json({ error: "Failed to delete" });
    }
  }
}

// Create router
function MakeRouter(wireHelper: WireHelper): express.Router {
  const restHandler = new RestHandler(
    new BookOperator(wireHelper.bookManager())
  );

  const router = express.Router();

  router.get("", restHandler.getBooks.bind(restHandler));
  router.get("/:id", restHandler.getBook.bind(restHandler));
  router.post("", restHandler.createBook.bind(restHandler));
  router.put("/:id", restHandler.updateBook.bind(restHandler));
  router.delete("/:id", restHandler.deleteBook.bind(restHandler));

  return router;
}

export function InitApp(wireHelper: WireHelper): express.Express {
  const app = express();

  // Middleware to parse JSON bodies
  app.use(express.json());

  // Define a health endpoint handler
  app.get("/", (req: Request, res: Response) => {
    res.status(200).json({ status: "ok" });
  });

  const r = MakeRouter(wireHelper);
  app.use("/books", r);
  return app;
}

移动 app.tsmain.ts,改成如下内容使其整洁:

import { WireHelper } from "@/application";
import { InitApp } from "@/adapter/router";

const port = process.env.PORT || 3000;

const c = {
  app: {
    port: Number(port),
  },
  db: {
    fileName: "test.db",
  },
};
const wireHelper = new WireHelper(c);
const app = InitApp(wireHelper);

app.listen(port, () => {
  console.log(`Running on port ${port}`);
});

重构完成。🎉赞!