在 Docker 上使用 Node.js 和 ElasticSearch 进行全文搜索

2025-05-25

在 Docker 上使用 Node.js 和 ElasticSearch 进行全文搜索

作者:Michele Riva✏️

全文搜索既令人恐惧又令人兴奋。一些流行的数据库(例如MySqlPostgres)是出色的数据存储解决方案……但就全文搜索性能而言,ElasticSearch无人能及。

对于那些不了解ElasticSearch 的人来说,它是一款基于Lucene构建的搜索引擎服务器,拥有出色的分布式架构支持。根据db-engines.com的数据,它是目前使用最广泛的搜索引擎。

在这篇文章中,我们将构建一个名为“报价数据库”的简单 REST 应用程序,它允许我们存储和搜索任意数量的报价。

我准备了一个JSON 文件,其中包含 5000 多条引言及其作者,我们将使用它作为填充 ElasticSearch 的起始数据。

您可以在此处找到该项目的存储库

LogRocket 免费试用横幅

设置 Docker

首先,我们不想在机器上安装 ElasticSearch。我们将使用 Docker 在容器中编排 Node.js 服务器和 ElasticSearch 实例,这将使我们能够部署一个包含所有依赖项的生产就绪应用程序。

让我们Dockerfile在项目根文件夹中创建一个:

FROM node:10.15.3-alpine

WORKDIR /usr/src/app

COPY package*.json ./

RUN npm install
RUN npm install -g pm2

COPY . ./

EXPOSE 3000
EXPOSE 9200

CMD npm run start
Enter fullscreen mode Exit fullscreen mode

如你所见,我们告诉 Docker 我们将运行Node.js 10.15.3-alpine运行时。我们还将在 下创建一个新的工作目录/usr/src/app,并将package.jsonpackage-lock.json文件复制到其中。这样,Docker 就能npm install在我们的 中运行WORKDIR,并安装所需的依赖项。

我们还将通过运行来安装PM2。Node.jsRUN npm install -g pm2运行时是单线程的,因此如果某个进程崩溃,则需要重新启动整个应用程序…… PM2会检查 Node.js 进程状态,并在应用程序因任何原因崩溃时重新启动它。

安装 PM2 后,我们将代码库复制到我们的WORKDIRCOPY . ./)中,并告诉 Docker 公开两个端口,3000这将公开我们的 RESTful 服务,以及9200,这将公开 ElasticSearch 服务(EXPOSE 3000EXPOSE 9200)。

最后但同样重要的一点是,我们告诉 Docker 哪个命令将启动 Node.js 应用程序npm run start

设置docker-compose

现在你可能会说:“太好了,我明白了!但是我该如何在 Docker 中处理 ElasticSearch 实例呢?我在 Dockerfile 中找不到它!” ……你说得对!这就是docker-compose 的用武之地。它允许我们编排多个 Docker 容器并在它们之间建立连接。所以,让我们写下这个docker-compose.yml文件,它将存储在我们的项目根目录中:

version: '3.6'
services:
  api:
    image: node:10.15.3-alpine
    container_name: tqd-node
    build: .
    ports:
      - 3000:3000
    environment:
     - NODE_ENV=local
     - ES_HOST=elasticsearch
     - NODE_PORT=3000
     - ELASTIC_URL=http://elasticsearch:9200
    volumes:
      - .:/usr/src/app/quotes
    command: npm run start
    links:
        - elasticsearch
    depends_on:
        - elasticsearch
    networks: 
      - esnet
  elasticsearch:
    container_name: tqd-elasticsearch
    image: docker.elastic.co/elasticsearch/elasticsearch:7.0.1
    volumes:
      - esdata:/usr/share/elasticsearch/data
    environment:
      - bootstrap.memory_lock=true
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
      - discovery.type=single-node
    logging:
      driver: none
    ports:
      - 9300:9300
      - 9200:9200
    networks: 
      - esnet
volumes:
  esdata:
networks:
  esnet:
Enter fullscreen mode Exit fullscreen mode

这比我们的 Dockerfile 稍微复杂一些,但让我们分析一下:

  • 我们声明docker-compose.yml正在使用哪个版本的文件(3.6
  • 我们声明我们的服务:
    • api,这就是我们的 Node.js 应用。就像在 Dockerfile 中一样,它需要node:10.15.3-alpine镜像。我们还为这个容器分配了一个名称tqd-node,然后使用build .命令调用之前创建的 Dockerfile。
    • 我们需要公开3000端口,因此我们将这些语句编写如下3000:3000。这意味着我们将从端口(容器内部)映射端口30003000我们的机器访问)。然后我们将设置一些环境变量。该值elasticsearch是一个引用文件elasticsearch内部服务的变量docker-compose.yml
    • 我们还想挂载一个卷/usr/src/app/quotes。这样,一旦我们重启容器,我们就能保留数据而不会丢失。
    • 再次,我们告诉 Docker 容器启动后需要执行哪个命令,然后设置到该服务的链接。我们还告诉 Docker 在服务启动elasticsearch启动该服务(使用指令)。apielasticsearchdepends_on
    • 最后但同样重要的是,我们告诉 Docker 将api服务连接到该esnet网络。这是因为每个容器都有自己的网络。这样,我们就可以称容器apielasticsearch服务共享同一个网络,以便它们能够使用相同的端口相互调用。
    • elasticsearch,也就是(你可能已经猜到了)我们的 ES 服务。它的配置与 ESapi服务非常相似。我们只需将logging指令设置为 即可截断其详细日志driver: none
  • 我们还声明了我们的卷,用于存储 ES 数据。
  • 我们宣布我们的网络,esnet

引导 Node.js 应用程序

现在我们需要创建我们的 Node.js 应用程序,所以让我们开始设置我们的package.json文件:

npm init -y
Enter fullscreen mode Exit fullscreen mode

现在我们需要安装一些依赖项:

npm i -s @elastic/elasticsearch body-parser cors dotenv express
Enter fullscreen mode Exit fullscreen mode

太棒了!我们的package.json文件应该如下所示:

{
  "name": "nodejselastic",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@elastic/elasticsearch": "^7.3.0",
    "body-parser": "^1.19.0",
    "cors": "^2.8.5",
    "dotenv": "^8.0.0",
    "express": "^4.17.1"
  }
}
Enter fullscreen mode Exit fullscreen mode

让我们在 Node.js 中实现 ElasticSearch 连接器。首先,我们需要创建一个新/src/elastic.js文件:

const { Client } = require("@elastic/elasticsearch");
                   require("dotenv").config();

const elasticUrl = process.env.ELASTIC_URL || "http://localhost:9200";
const esclient   = new Client({ node: elasticUrl });
const index      = "quotes";
const type       = "quotes";
Enter fullscreen mode Exit fullscreen mode

如你所见,我们在这里设置了一些非常有用的常量。首先,我们使用 ElasticSearch 的官方Node.js SDK创建一个新连接,然后定义一个索引("quotes")和一个索引类型("quotes"同样,我们稍后会了解它们的含义)。

现在我们需要在 ElasticSearch 上创建索引。你可以将“索引”理解为 SQL 中的“数据库”。ElasticSearch 是一个 NoSQL 数据库,这意味着它没有表,只存储 JSON 文档。索引是一个逻辑命名空间,它映射到一个或多个主分片,并且可以有零个或多个副本分片。你可以在这里阅读更多关于 ElasticSearch 索引的内容。

现在让我们定义一个创建索引的函数:

async function createIndex(index) { 
  try {
    await esclient.indices.create({ index });
    console.log(`Created index ${index}`);
  } catch (err) {
    console.error(`An error occurred while creating the index ${index}:`);
    console.error(err);
  }
}
Enter fullscreen mode Exit fullscreen mode

现在我们需要另一个函数来创建引文的映射。该映射定义了文档的模式和类型:

async function setQuotesMapping () {
  try {
    const schema = {
      quote: {
        type: "text" 
      },
      author: {
        type: "text"
      }
    };

    await esclient.indices.putMapping({ 
      index, 
      type,
      include_type_name: true,
      body: { 
        properties: schema 
      } 
    })

    console.log("Quotes mapping created successfully");
  } catch (err) {
    console.error("An error occurred while setting the quotes mapping:");
    console.error(err);
  }
}
Enter fullscreen mode Exit fullscreen mode

正如您所见,我们正在为我们的文档定义模式,并将其插入到我们的文件中index

现在我们假设 ElasticSearch 是一个庞大的系统,启动可能需要几秒钟。在 ES 准备就绪之前我们无法连接到它,因此我们需要一个函数来检查 ES 服务器是否已准备就绪:

function checkConnection() {
  return new Promise(async (resolve) => {
    console.log("Checking connection to ElasticSearch...");
    let isConnected = false;
    while (!isConnected) {
      try {
        await esclient.cluster.health({});
        console.log("Successfully connected to ElasticSearch");
        isConnected = true;
      // eslint-disable-next-line no-empty
      } catch (_) {
      }
    }
    resolve(true);
  });
}
Enter fullscreen mode Exit fullscreen mode

如你所见,我们返回了一个 Promise。这是因为通过使用 ,async/await我们可以停止整个 Node.js 进程,直到这个 Promise 解析完成,并且只有在连接到 ES 后才会解析。这样,我们就强制 Node.js 等待 ES 完成解析后再启动。

ElasticSearch 已经搞定!现在导出函数:

module.exports = {
  esclient,
  setQuotesMapping,
  checkConnection,
  createIndex,
  index,
  type
};
Enter fullscreen mode Exit fullscreen mode

太棒了!让我们看看整个elastic.js文件:

const { Client } = require("@elastic/elasticsearch");
                   require("dotenv").config();
const elasticUrl = process.env.ELASTIC_URL || "http://localhost:9200";
const esclient   = new Client({ node: elasticUrl });
const index      = "quotes";
const type       = "quotes";
/**
 * @function createIndex
 * @returns {void}
 * @description Creates an index in ElasticSearch.
 */
async function createIndex(index) {
  try {
    await esclient.indices.create({ index });
    console.log(`Created index ${index}`);
  } catch (err) {
    console.error(`An error occurred while creating the index ${index}:`);
    console.error(err);
  }
}
/**
 * @function setQuotesMapping,
 * @returns {void}
 * @description Sets the quotes mapping to the database.
 */
async function setQuotesMapping () {
  try {
    const schema = {
      quote: {
        type: "text" 
      },
      author: {
        type: "text"
      }
    };

    await esclient.indices.putMapping({ 
      index, 
      type,
      include_type_name: true,
      body: { 
        properties: schema 
      } 
    })

    console.log("Quotes mapping created successfully");

  } catch (err) {
    console.error("An error occurred while setting the quotes mapping:");
    console.error(err);
  }
}
/**
 * @function checkConnection
 * @returns {Promise<Boolean>}
 * @description Checks if the client is connected to ElasticSearch
 */
function checkConnection() {
  return new Promise(async (resolve) => {
    console.log("Checking connection to ElasticSearch...");
    let isConnected = false;
    while (!isConnected) {
      try {
        await esclient.cluster.health({});
        console.log("Successfully connected to ElasticSearch");
        isConnected = true;
      // eslint-disable-next-line no-empty
      } catch (_) {
      }
    }
    resolve(true);
  });
}
module.exports = {
  esclient,
  setQuotesMapping,
  checkConnection,
  createIndex,
  index,
  type
};
Enter fullscreen mode Exit fullscreen mode

使用引号填充 ElasticSearch

现在我们需要将引言填充到 ES 实例中。这听起来可能很简单,但相信我,它确实很棘手。

让我们创建一个新文件/src/data/index.js

const elastic = require("../elastic");
const quotes  = require("./quotes.json");

const esAction = {
  index: {
    _index: elastic.index,
    _type: elastic.type
  }
};
Enter fullscreen mode Exit fullscreen mode

如您所见,我们导入了elastic刚刚创建的模块以及存储在 JSON 文件中的引文/src/data/quotes.json。我们还创建了一个名为 的对象esAction,它将告诉 ES 如何在插入文档后对其进行索引。

现在我们需要一个脚本来填充数据库。我们还需要创建一个具有以下结构的对象数组:

[
  {
    index: {
      _index: elastic.index,
      _type:  elastic.type
    }
  },
  {
    author: "quote author",
    quote:  "quote"
  },
  ...
]
Enter fullscreen mode Exit fullscreen mode

如你所见,对于我们要插入的每条引文,我们都需要将其映射到 ElasticSearch。因此,我们需要这样做:

async function populateDatabase() {
  const docs = [];
  for (const quote of quotes) {
    docs.push(esAction);
    docs.push(quote);
  }
  return elastic.esclient.bulk({ body: docs });
}
Enter fullscreen mode Exit fullscreen mode

太棒了!现在让我们创建主文件/src/main.js,看看如何构建到目前为止所写的内容:

const elastic = require("./elastic");
const data    = require("./data");
                require("dotenv").config();

(async function main() {

  const isElasticReady = await elastic.checkConnection();
  if (isElasticReady) {
    const elasticIndex = await elastic.esclient.indices.exists({index: elastic.index});

    if (!elasticIndex.body) {
      await elastic.createIndex(elastic.index);
      await elastic.setQuotesMapping();
      await data.populateDatabase()
    }
  }

})();
Enter fullscreen mode Exit fullscreen mode

让我们分析一下上面的代码。我们创建一个自执行的 main 函数,用于检查 ES 连接。只有在 ES 连接成功后,代码才会执行​​。ES 准备就绪后,我们会检查quotes索引是否存在。如果不存在,我们会创建索引,设置其映射并填充数据库。显然,我们只会在第一次启动服务器时执行这些操作!

创建 RESTful API

现在我们需要创建 RESTful 服务器。我们将使用 Express.js,它是目前最流行的 Node.js 服务器构建框架。

我们将从该文件开始/src/server/index.js

const express      = require("express");
const cors         = require("cors");
const bodyParser   = require("body-parser");
const routes       = require("./routes");
                     require("dotenv").config();

const app  = express();
const port = process.env.NODE_PORT || 3000;

function start() {
  return  app.use(cors())
             .use(bodyParser.urlencoded({ extended: false }))
             .use(bodyParser.json())
             .use("/quotes",routes)
             .use((_req, res) => res.status(404).json({ success: false,error: "Route not found" }))
             .listen(port, () => console.log(`Server ready on port ${port}`));
}

module.exports = {
  start
};
Enter fullscreen mode Exit fullscreen mode

如您所见,它只是一个标准的 Express.js 服务器,我们不会在其上花费太多时间。

让我们看看我们的/src/server/routes/index.js文件:

const express    = require("express");
const controller = require("../controllers");
const routes     = express.Router();

routes.route("/").get(controller.getQuotes);
routes.route("/new").post(controller.addQuote);

module.exports = routes;
Enter fullscreen mode Exit fullscreen mode

我们只需创建两个端点:

  1. GET /,将返回与我们的查询字符串参数匹配的引号列表。
  2. POST /new/,将允许我们发布新的报价并将其存储在 ElasticSearch 中。

现在让我们看看我们的/src/server/controllers/index.js文件:

const model = require("../models");

async function getQuotes(req, res) {
  const query  = req.query;
  if (!query.text) {
    res.status(422).json({
      error: true,
      data: "Missing required parameter: text"
    });
    return;
  }
  try {
    const result = await model.getQuotes(req.query);
    res.json({ success: true, data: result });
  } catch (err) {
    res.status(500).json({ success: false, error: "Unknown error."});
  }
}

async function addQuote(req, res) {
  const body = req.body;
  if (!body.quote || !body.author) {
    res.status(422).json({
      error: true,
      data: "Missing required parameter(s): 'body' or 'author'"
    });
    return;
  }
  try {
    const result = await model.insertNewQuote(body.quote, body.author);
    res.json({ 
      success: true, 
      data: {
        id:     result.body._id,
        author: body.author,
        quote:  body.quote
      } 
    });
  } catch (err) {
    res.status(500).json({ success: false, error: "Unknown error."});
  }
}
module.exports = {
  getQuotes,
  addQuote
};
Enter fullscreen mode Exit fullscreen mode

这里我们主要定义两个函数:

  1. getQuotes,它至少需要一个查询字符串参数 –text
  2. addQuote,需要两个参数——authorquote

ElasticSearch 接口委托给了我们的/src/server/models/index.js。这种结构有助于我们维护一个类似 MVC 的架构。

让我们看看我们的模型:

const { esclient, index, type } = require("../../elastic");

async function getQuotes(req) {
  const query = {
    query: {
      match: {
        quote: {
          query: req.text,
          operator: "and",
          fuzziness: "auto"
        }
      }
    }
  }

  const { body: { hits } } = await esclient.search({
    from:  req.page  || 0,
    size:  req.limit || 100,
    index: index, 
    type:  type,
    body:  query
  });

  const results = hits.total.value;
  const values  = hits.hits.map((hit) => {
    return {
      id:     hit._id,
      quote:  hit._source.quote,
      author: hit._source.author,
      score:  hit._score
    }
  });

  return {
    results,
    values
  }
}
Enter fullscreen mode Exit fullscreen mode

如您所见,我们通过选择包含给定单词或短语的每个引文来编写 ElasticSearch 查询。

然后,我们生成查询,设置page和的limit值,例如,我们可以将它们传递给查询字符串http://localhost:3000/quotes?text=love&page=1&limit=100。如果这些值不是通过查询字符串传递的,我们将恢复为它们的默认值。

ElasticSearch 返回的数据量非常大,但我们只需要四样东西:

  1. 报价编号
  2. 引文本身
  3. 引述作者
  4. 分数

分数表示引文与搜索词的接近程度。获得这些值后,我们会将其与结果总数一起返回,这在前端对结果进行分页时可能会很有用。

现在我们需要为我们的模型创建最后一个函数insertNewQuote

async function insertNewQuote(quote, author) {
  return esclient.index({
    index,
    type,
    body: {
      quote,
      author
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

这个功能非常简单,我们只需将引文和作者发布到我们的索引并将查询结果返回给控制器。

现在完整的/src/server/models/index.js文件应该如下所示:

const { esclient, index, type } = require("../../elastic");

async function getQuotes(req) {
  const query = {
    query: {
      match: {
        quote: {
          query: req.text,
          operator: "and",
          fuzziness: "auto"
        }
      }
    }
  }

  const { body: { hits } } = await esclient.search({
    from:  req.page  || 0,
    size:  req.limit || 100,
    index: index, 
    type:  type,
    body:  query
  });

  const results = hits.total.value;

  const values  = hits.hits.map((hit) => {
    return {
      id:     hit._id,
      quote:  hit._source.quote,
      author: hit._source.author,
      score:  hit._score
    }
  });

  return {
    results,
    values
  }
}

async function insertNewQuote(quote, author) {
  return esclient.index({
    index,
    type,
    body: {
      quote,
      author
    }
  })
}

module.exports = {
  getQuotes,
  insertNewQuote
}
Enter fullscreen mode Exit fullscreen mode

大功告成!我们只需要在package.json文件中设置好启动脚本,就可以开始使用了:

"scripts": {
  "start": "pm2-runtime start ./src/main.js --name node_app",
  "stop": "pm2-runtime stop node_app "
}
Enter fullscreen mode Exit fullscreen mode

我们还需要更新/src/main.js脚本,以便在 ElasticSearch 连接后启动 Express.js 服务器:

const elastic = require("./elastic");
const server  = require("./server");
const data    = require("./data");
                require("dotenv").config();

(async function main() {
  const isElasticReady = await elastic.checkConnection();

  if (isElasticReady) {
    const elasticIndex = await elastic.esclient.indices.exists({index: elastic.index});

    if (!elasticIndex.body) {
      await elastic.createIndex(elastic.index);
      await elastic.setQuotesMapping();
      await data.populateDatabase()
    }

    server.start();
  }
})();
Enter fullscreen mode Exit fullscreen mode

启动应用程序

我们现在准备使用docker-compose启动我们的应用程序!

只需运行以下命令:

docker-compose up
Enter fullscreen mode Exit fullscreen mode

您需要等到 Docker 下载 ElasticSearch 和 Node.js 镜像,然后它将启动您的服务器,您就可以查询您的 REST 端点了!

让我们用几个 cURL 调用来测试一下:

curl localhost:3000/quotes?text=love&limit=3

{
  "success": true,
  "data": {
    "results": 716,
    "values": [
      {
        "id": "JDE3kGwBuLHMiUvv1itT",
        "quote": "There is only one happiness in life, to love and be loved.",
        "author": "George Sand",
        "score": 6.7102118
      },
      {
        "id": "JjE3kGwBuLHMiUvv1itT",
        "quote": "Live through feeling and you will live through love. For feeling is the language of the soul, and feeling is truth.",
        "author": "Matt Zotti",
        "score": 6.2868223
      },
      {
        "id": "NTE3kGwBuLHMiUvv1iFO",
        "quote": "Genuine love should first be directed at oneself if we do not love ourselves, how can we love others?",
        "author": "Dalai Lama",
        "score": 5.236455
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

因此,正如您所见,我们决定将结果限制为3,但有超过 713 条引言!

我们可以通过调用以下命令轻松获取接下来的三个报价:

curl localhost:3000/quotes?text=love&limit=3&page=2

{
  "success": true,
  "data": {
    "results": 716,
    "values": [
      {
        "id": "SsyHkGwBrOFNsaVmePwE",
        "quote": "Forgiveness is choosing to love. It is the first skill of self-giving love.",
        "author": "Mohandas Gandhi",
        "score": 4.93597
      },
      {
        "id": "rDE3kGwBuLHMiUvv1idS",
        "quote": "Neither a lofty degree of intelligence nor imagination nor both together go to the making of genius. Love, love, love, that is the soul of genius.",
        "author": "Wolfgang Amadeus Mozart",
        "score": 4.7821507
      },
      {
        "id": "TjE3kGwBuLHMiUvv1h9K",
        "quote": "Speak low, if you speak love.",
        "author": "William Shakespeare",
        "score": 4.6697206
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

如果需要插入新的报价怎么办?只需调用/quotes/new端点即可!

curl --request POST \
     --url http://localhost:3000/quotes/new \
     --header 'content-type: application/json' \
     --data '{
        "author": "Michele Riva",
        "quote": "Using Docker and ElasticSearch is challenging, but totally worth it."
}'
Enter fullscreen mode Exit fullscreen mode

响应将是:

{
  "success": true,
  "data": {
    "id": "is2QkGwBrOFNsaVmFAi8",
    "author": "Michele Riva",
    "quote": "Using Docker and ElasticSearch is challenging, but totally worth it."
  }
}
Enter fullscreen mode Exit fullscreen mode

结论

Docker 使我们管理依赖项及其部署变得异常简单。从此以后,我们可以轻松地将应用程序托管在HerokuAWS ECSGoogle Cloud Container或任何其他基于 Docker 的服务上,而无需费力地设置服务器及其极其复杂的配置。

下一步是什么?

  • 了解如何使用Kubernetes来扩展您的容器并协调更多 ElasticSearch 实例!
  • 创建一个新端点,用于更新现有报价。错误可能会发生!
  • 那么删除引言呢?该如何实现呢?
  • 用标签保存您的名言(例如,关于爱情、健康、艺术的名言)会很棒...尝试更新您的quotes索引!

软件开发很有趣。有了 Docker、Node 和 ElasticSearch,就更棒了!


编者注:觉得这篇文章有什么问题?您可以在这里找到正确版本

插件:LogRocket,一个用于 Web 应用的 DVR

 
LogRocket 仪表板免费试用横幅
 
LogRocket是一款前端日志工具,可让您重放问题,就像它们发生在您自己的浏览器中一样。您无需猜测错误发生的原因,也无需要求用户提供屏幕截图和日志转储,LogRocket 允许您重放会话以快速了解问题所在。它可与任何应用程序完美兼容,无论使用哪种框架,并且提供插件来记录来自 Redux、Vuex 和 @ngrx/store 的更多上下文。
 
除了记录 Redux 操作和状态之外,LogRocket 还记录控制台日志、JavaScript 错误、堆栈跟踪、带有标头 + 正文的网络请求/响应、浏览器元数据以及自定义日志。它还会对 DOM 进行插桩,以记录页面上的 HTML 和 CSS,即使是最复杂的单页应用程序,也能重现像素完美的视频。
 
免费试用


使用 Node.js 和 ElasticSearch 在 Docker 上进行全文搜索一文首先出现在LogRocket 博客上。

文章来源:https://dev.to/bnevilleoneill/full-text-search-with-node-js-and-elasticsearch-on-docker-146k
PREV
如何使用 React 构建管理面板
NEXT
使用 Node.js 构建您自己的 Web 分析仪表板