» Node.js:使用Kafka构建事件驱动微服务 » 2. 生产者:Web服务 » 2.4 创建和搜索图书

创建和搜索图书

首先让我们来添加创建、搜索图书的 API,然后再放入一些测试数据供后续使用。

创建图书 API

创建 src/domain/gateway/book_manager.ts:

import { Book } from "../model";

export interface BookManager {
  createBook(b: Book): Promise<number>;
}

此处也是在使用4层架构。项目越大,该架构的好处越明显。阅读更多

创建 src/domain/gateway/index.ts:

export { BookManager } from "./book_manager";

记得为其他子目录创建 index.ts 文件。

安装 mysql 依赖:

npm install mysql2

准备 MySQL 数据库:

  • 在你的机器上安装 MySQL 并启动。
  • 创建一个库,名为 lr_event_book
CREATE DATABASE lr_event_book CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
  • 创建用户 test_user:
CREATE USER 'test_user'@'localhost' IDENTIFIED BY 'test_pass';
GRANT ALL PRIVILEGES ON lr_event_book.* TO 'test_user'@'localhost';
FLUSH PRIVILEGES;

创建 src/infrastructure/database/mysql.ts:

import mysql, { ResultSetHeader } from "mysql2";

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

export class MySQLPersistence implements BookManager {
  private db: mysql.Connection;
  private page_size: number;

  constructor(dsn: string, page_size: number) {
    this.page_size = page_size;
    this.db = mysql.createConnection(dsn);
    this.db.addListener("error", (err) => {
      console.error("Error connecting to MySQL:", err.message);
    });

    this.db.execute(
      `CREATE TABLE IF NOT EXISTS books (
        id INT AUTO_INCREMENT PRIMARY KEY,
        title VARCHAR(255) NOT NULL,
        author VARCHAR(255) NOT NULL,
        published_at VARCHAR(15) NOT NULL,
        description TEXT NOT NULL,
        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        )`,
      (err) => {
        if (err) {
          console.error("Error in MySQL:", err.message);
        } else {
          console.log("Successfully initialized tables.");
        }
      }
    );
  }

  async createBook(b: Book): Promise<number> {
    const { title, author, published_at, description } = b;
    const [result] = await this.db
      .promise()
      .query(
        "INSERT INTO books (title, author, published_at, description) VALUES (?, ?, ?, ?)",
        [title, author, published_at, description]
      );
    return (result as ResultSetHeader).insertId;
  }

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

创建 src/infrastructure/config/config.ts:

import { readFileSync } from "fs";

interface DBConfig {
  dsn: string;
}

interface ApplicationConfig {
  port: number;
  page_size: number;
  templates_dir: string;
}

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

export function parseConfig(filename: string): Config {
  return JSON.parse(readFileSync(filename, "utf-8"));
}

创建 config.json:

{
  "app": {
    "port": 3000,
    "page_size": 5,
    "templates_dir": "src/adapter/templates"
  },
  "db": {
    "dsn": "mysql://test_user:test_pass@127.0.0.1:3306/lr_event_book?charset=utf8mb4"
  }
}

警醒:
不要直接 git 提交 config.json。可能会导致敏感数据泄露。如果非要提交的话,建议只提交配置格式模板。
比如:

{
 "app": {
   "port": 3000
 },
 "db": {
   "dsn": ""
 }
}

创建 src/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;
  }
}

创建 src/application/wire_helper.ts:

import { Config } from "../infrastructure/config";
import { BookManager } from "../domain/gateway";
import { MySQLPersistence } from "../infrastructure/database";

// WireHelper is the helper for dependency injection
export class WireHelper {
  private sql_persistence: MySQLPersistence;

  constructor(c: Config) {
    this.sql_persistence = new MySQLPersistence(c.db.dsn, c.app.page_size);
  }

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

创建 src/adapter/router.ts:

import express, { Request, Response } from "express";
import { engine } from "express-handlebars";

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;
  }

  // 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" });
    }
  }
}

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

  const router = express.Router();
  router.get("/", (req, res) => {
    // Render the 'index.handlebars' template, passing data to it
    res.render("index", { layout: false, title: "LiteRank Book Store" });
  });
  router.post("/api/books", restHandler.createBook.bind(restHandler));
  return router;
}

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

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

  // Set Handlebars as the template engine
  app.engine("handlebars", engine());
  app.set("view engine", "handlebars");
  // Set the directory for template files
  app.set("views", templates_dir);

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

移动 templatessrc/adapter/templates

替换 src/app.ts 中内容为如下代码:

import { WireHelper } from "./application";
import { InitApp } from "./adapter/router";
import { parseConfig } from "./infrastructure/config";

const config_filename = "config.json";

const c = parseConfig(config_filename);
const wireHelper = new WireHelper(c);
const app = InitApp(c.app.templates_dir, wireHelper);

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

再次运行 server,然后使用 curl 进行测试:

npm run dev
curl --request POST \
  --url http://localhost:3000/api/books \
  --header 'Content-Type: application/json' \
  --data '{
	"title": "The Great Gatsby",
	"author": "F. Scott Fitzgerald",
	"published_at": "1925-04-10",
	"description": "A novel depicting the opulent lives of wealthy Long Island residents during the Jazz Age."
}'

样例响应:

{
  "title": "The Great Gatsby",
  "author": "F. Scott Fitzgerald",
  "published_at": "1925-04-10",
  "description": "A novel depicting the opulent lives of wealthy Long Island residents during the Jazz Age.",
  "id": 13
}

放入测试数据

curl -X POST -H "Content-Type: application/json" -d '{"title": "To Kill a Mockingbird", "author": "Harper Lee", "published_at": "1960-07-11", "description": "A novel set in the American South during the 1930s, dealing with themes of racial injustice and moral growth."}' http://localhost:3000/api/books
curl -X POST -H "Content-Type: application/json" -d '{"title": "1984", "author": "George Orwell", "published_at": "1949-06-08", "description": "A dystopian novel depicting a totalitarian regime, surveillance, and propaganda."}' http://localhost:3000/api/books
curl -X POST -H "Content-Type: application/json" -d '{"title": "Pride and Prejudice", "author": "Jane Austen", "published_at": "1813-01-28", "description": "A classic novel exploring the themes of love, reputation, and social class in Georgian England."}' http://localhost:3000/api/books
curl -X POST -H "Content-Type: application/json" -d '{"title": "The Catcher in the Rye", "author": "J.D. Salinger", "published_at": "1951-07-16", "description": "A novel narrated by a disaffected teenager, exploring themes of alienation and identity."}' http://localhost:3000/api/books
curl -X POST -H "Content-Type: application/json" -d '{"title": "The Lord of the Rings", "author": "J.R.R. Tolkien", "published_at": "1954-07-29", "description": "A high fantasy epic following the quest to destroy the One Ring and defeat the Dark Lord Sauron."}' http://localhost:3000/api/books
curl -X POST -H "Content-Type: application/json" -d '{"title": "Moby-Dick", "author": "Herman Melville", "published_at": "1851-10-18", "description": "A novel exploring themes of obsession, revenge, and the nature of good and evil."}' http://localhost:3000/api/books
curl -X POST -H "Content-Type: application/json" -d '{"title": "The Hobbit", "author": "J.R.R. Tolkien", "published_at": "1937-09-21", "description": "A fantasy novel set in Middle-earth, following the adventure of Bilbo Baggins and the quest for treasure."}' http://localhost:3000/api/books
curl -X POST -H "Content-Type: application/json" -d '{"title": "The Adventures of Huckleberry Finn", "author": "Mark Twain", "published_at": "1884-12-10", "description": "A novel depicting the journey of a young boy and an escaped slave along the Mississippi River."}' http://localhost:3000/api/books
curl -X POST -H "Content-Type: application/json" -d '{"title": "War and Peace", "author": "Leo Tolstoy", "published_at": "1869-01-01", "description": "A novel depicting the Napoleonic era in Russia, exploring themes of love, war, and historical determinism."}' http://localhost:3000/api/books
curl -X POST -H "Content-Type: application/json" -d '{"title": "Alice’s Adventures in Wonderland", "author": "Lewis Carroll", "published_at": "1865-11-26", "description": "A children’s novel featuring a young girl named Alice who falls into a fantastical world populated by peculiar creatures."}' http://localhost:3000/api/books
curl -X POST -H "Content-Type: application/json" -d '{"title": "The Odyssey", "author": "Homer", "published_at": "8th Century BC", "description": "An ancient Greek epic poem attributed to Homer, detailing the journey of Odysseus after the Trojan War."}' http://localhost:3000/api/books

搜索图书 API

修改 src/domain/gateway/book_manager.ts:

@@ -2,4 +2,5 @@ import { Book } from "../model";
 
 export interface BookManager {
   createBook(b: Book): Promise<number>;
+  getBooks(offset: number, keyword: string): Promise<Book[]>;
 }

修改 src/infrastructure/database/mysql.ts:

@@ -44,6 +44,21 @@ export class MySQLPersistence implements BookManager {
     return (result as ResultSetHeader).insertId;
   }
 
+  async getBooks(offset: number, keyword: string): Promise<Book[]> {
+    let query = "SELECT * FROM books";
+    let params: (string | number)[] = [];
+
+    if (keyword) {
+      query += " WHERE title LIKE ? OR author LIKE ? OR description LIKE ?";
+      params = [`%${keyword}%`, `%${keyword}%`, `%${keyword}%`];
+    }
+
+    query += " LIMIT ?, ?";
+    params.push(offset, this.page_size);
+    const [rows] = await this.db.promise().query(query, params);
+    return rows as Book[];
+  }
+
   close(): void {
     this.db.end();
   }

修改 src/application/executor/book_operator.ts:

@@ -13,4 +13,8 @@ export class BookOperator {
     b.id = id;
     return b;
   }
+
+  async getBooks(offset: number, query: string): Promise<Book[]> {
+    return await this.bookManager.getBooks(offset, query);
+  }
 }

修改 src/adapter/router.ts:

@@ -12,6 +12,24 @@ class RestHandler {
     this.bookOperator = bookOperator;
   }
 
+  // Get all books
+  public async getBooks(req: Request, res: Response): Promise<void> {
+    let offset = parseInt(req.query.o as string);
+    if (isNaN(offset)) {
+      offset = 0;
+    }
+    try {
+      const books = await this.bookOperator.getBooks(
+        offset,
+        req.query.q as string
+      );
+      res.status(200).json(books);
+    } catch (err) {
+      console.error(`Failed to get books: ${err}`);
+      res.status(404).json({ error: "Failed to get books" });
+    }
+  }
+
   // Create a new book
   public async createBook(req: Request, res: Response): Promise<void> {
     try {
@@ -35,6 +53,7 @@ function MakeRouter(wireHelper: WireHelper): express.Router {
     // Render the 'index.handlebars' template, passing data to it
     res.render("index", { layout: false, title: "LiteRank Book Store" });
   });
+  router.get("/api/books", restHandler.getBooks.bind(restHandler));
   router.post("/api/books", restHandler.createBook.bind(restHandler));
   return router;
 }

重启后再次使用 curl 测试:

curl --request GET --url 'http://localhost:3000/api/books?q=love'

样例响应:

[
  {
    "id": 4,
    "title": "Pride and Prejudice",
    "author": "Jane Austen",
    "published_at": "1813-01-28",
    "description": "A classic novel exploring the themes of love, reputation, and social class in Georgian England.",
    "created_at": "2024-04-02T13:02:59.314Z"
  },
  {
    "id": 10,
    "title": "War and Peace",
    "author": "Leo Tolstoy",
    "published_at": "1869-01-01",
    "description": "A novel depicting the Napoleonic era in Russia, exploring themes of love, war, and historical determinism.",
    "created_at": "2024-04-02T13:02:59.420Z"
  }
]

在首页上展示图书

更新 src/adapter/router.ts:

@@ -12,6 +12,18 @@ class RestHandler {
     this.bookOperator = bookOperator;
   }
 
+  public async indexPage(req: Request, res: Response): Promise<void> {
+    let books: Book[];
+    try {
+      books = await this.bookOperator.getBooks(0, "");
+    } catch (err) {
+      console.warn(`Failed to get books: ${err}`);
+      books = [];
+    }
+    // Render the 'index.handlebars' template, passing data to it
+    res.render("index", { layout: false, title: "LiteRank Book Store", books });
+  }
+
   // Get all books
   public async getBooks(req: Request, res: Response): Promise<void> {
     let offset = parseInt(req.query.o as string);
@@ -49,10 +61,7 @@ function MakeRouter(wireHelper: WireHelper): express.Router {
   );
 
   const router = express.Router();
-  router.get("/", (req, res) => {
-    // Render the 'index.handlebars' template, passing data to it
-    res.render("index", { layout: false, title: "LiteRank Book Store" });
-  });
+  router.get("/", restHandler.indexPage.bind(restHandler));
   router.get("/api/books", restHandler.getBooks.bind(restHandler));
   router.post("/api/books", restHandler.createBook.bind(restHandler));
   return router;

更新 src/adapter/templates/index.handlebars:

@@ -21,6 +21,20 @@
                 class="w-full px-4 py-2 rounded-md border-gray-300 focus:outline-none focus:border-blue-500">
         </div>
 
+        <!-- Books Section -->
+        <div class="mb-8">
+            <h2 class="text-2xl font-bold mb-4">Books</h2>
+            <div class="grid grid-cols-4 gap-2">
+                {{#each books}}
+                <div class="bg-white p-4 rounded-md border-gray-300 shadow mt-2">
+                    <div><b>{{this.title}}</b></div>
+                    <div class="text-gray-500 text-sm">{{this.published_at}}</div>
+                    <div class="italic text-sm">{{this.author}}</div>
+                </div>
+                {{/each}}
+            </div>
+        </div>
+
         <!-- Trends Section -->
         <div class="mb-8">
             <h2 class="text-2xl font-bold mb-4">Trends</h2>

重启 server 并刷新页面,你将看到图书列表。

在首页上搜索图书

更新 src/adapter/router.ts:

@@ -14,14 +14,20 @@ class RestHandler {
 
   public async indexPage(req: Request, res: Response): Promise<void> {
     let books: Book[];
+    const q = req.query.q as string;
     try {
-      books = await this.bookOperator.getBooks(0, "");
+      books = await this.bookOperator.getBooks(0, q);
     } catch (err) {
       console.warn(`Failed to get books: ${err}`);
       books = [];
     }
     // Render the 'index.handlebars' template, passing data to it
-    res.render("index", { layout: false, title: "LiteRank Book Store", books });
+    res.render("index", {
+      layout: false,
+      title: "LiteRank Book Store",
+      books,
+      q,
+    });
   }
 
   // Get all books

更新 src/adapter/templates/index.handlebars:

@@ -12,18 +12,21 @@
 
 <body class="bg-gray-100 p-2">
     <div class="container mx-auto py-8">
-        <h1 class="text-4xl font-bold">{{ title }}</h1>
+        <h1 class="text-4xl font-bold"><a href="/">{{ title }}</a></h1>
 
         <!-- Search Bar Section -->
         <div class="mb-8">
             <h2 class="text-2xl font-bold mb-4 mt-6">Search</h2>
-            <input type="text" placeholder="Search for books..."
-                class="w-full px-4 py-2 rounded-md border-gray-300 focus:outline-none focus:border-blue-500">
+            <form class="flex">
+                <input type="text" name="q" value="{{q}}" placeholder="Search for books..."
+                    class="flex-grow px-4 py-2 rounded-l-md border-gray-300 focus:outline-none focus:border-blue-500">
+                <button type="submit" class="px-4 py-2 bg-blue-500 text-white rounded-r-md">Search</button>
+            </form>
         </div>
 
         <!-- Books Section -->
         <div class="mb-8">
-            <h2 class="text-2xl font-bold mb-4">Books</h2>
+            <h2 class="text-2xl font-bold mb-4">{{#if q}}Keyword: “{{q}}“{{else}}Books{{/if}}</h2>
             <div class="grid grid-cols-4 gap-2">
                 {{#each books}}
                 <div class="bg-white p-4 rounded-md border-gray-300 shadow mt-2">

重启 server,刷新页面。

搜索结果如下所示:

search results

上页下页