教程:FullStackOpen2022/Part 3 - 用NodeJS和Express写服务端程序

前端部分:FullStackOpen:phonebook app / front-end 要点总结

后端部分:FullStackOpen:phonebook app / back-end 要点总结

App:React Phonebook App


1. 使用 MongoDB Atlas 创建 MongoDB

MongoDB 是一个所谓的文档数据库。文档数据库在组织数据的方式以及它们所支持的查询语言方面不同于关系数据库。文档数据库通常被归类为NoSQL的术语集。

我们选择的 MongoDB 提供者是 MongoDB Atlas

步骤:

  1. 创建并登录 MongoDB Atlas 账户。
  2. 创建一个集群(Create/Build a Cluster)。
  3. 选择任一 Free Tier 的提供商和地区。
  4. 等待 Cluster 准备好,这大约需要几分钟,在 Cluster 准备好之前不要继续。
  5. 在 Database Access 中为数据库创建用户凭据,用来让应用连接到云端数据库。
  6. 授予用户读写数据库的权限。
  7. 在 Network Access 中定义允许访问数据库的 IP 地址。为了方便,可以允许所有访问的 IP 地址:0.0.0.0/0
  8. 回到 Cluster,点击 Connect 连接数据库,选择对应驱动,获得可以添加到应用的 MongoDB 客户端库的数据库地址。例:mongodb+srv://fullstack:<PASSWORD>@cluster0-ostce.mongodb.net/test?retryWrites=true

2. 使用 mongoose 连接数据库

可以通过官方 MongoDb Node.js 驱动程序库直接从 JavaScript 代码中使用数据库,但是使用起来相当麻烦。应使用提供更高级别 API 的 mongoose 库。

使用 mongoose

安装 Mongoose:

npm install mongoose

创建 mongo.js 文件:

const mongoose = require("mongoose");

if (process.argv.length < 5) {
  console.log("Please provide arguments: password, name, number");
  process.exit(1);
}

const db_name = "persons";
const password = process.argv[2];

const url = `mongodb+srv://fullstackopen:${password}@cluster0.erk80.mongodb.net/${db_name}?retryWrites=true&w=majority`;

mongoose.connect(url);
// Mongoose 6 always behaves as if useNewUrlParser, useUnifiedTopology, and useCreateIndex are true, and useFindAndModify is false. Please remove these options from your code.

const personSchema = new mongoose.Schema({
  name: String,
  number: String,
});

const Person = mongoose.model("Person", personSchema);

const person = new Person({
  name: process.argv[3],
  number: process.argv[4],
});

person.save().then((savedPerson) => {
  console.log(
    `added ${savedPerson.name} number ${savedPerson.number} to phonebook`
  );

  mongoose.connection.close();
});

Person.find({}).then((result) => {
  console.log("phonebook: ");
  result.forEach((person) => {
    console.log(person.name, person.number);
  });

  mongoose.connection.close();
});

该代码假定将用命令行传递参数。可以用 process.argv[n] 访问参数。

当使用命令 node mongo.js {password} {name} {number} 运行代码时,Mongo 将向数据库添加一个新文档。

创建 Schema 和 Model

在建立到数据库的连接之后,我们为一个 person 定义模式 Schema 和匹配的模型 Model:

const personSchema = new mongoose.Schema({
  name: String,
  number: String,
});

const Person = mongoose.model("Person", personSchema);

在 Person 模型定义中,第一个 "Person" 参数是模型的单数名。集合的名称将是小写的复数 persons。mongoose 约定是当模式以单数(Person)引用集合时,自动将其命名为复数(persons)。

像 Mongo 这样的文档数据库是 schemaaless,这意味着数据库本身并不关心存储在数据库中的数据的结构,可以在同一集合中存储具有完全不同字段的文档。

创建和保存对象

const person = new Person({
  name: process.argv[3],
  number: process.argv[4],
});

person.save().then((savedPerson) => {
  console.log(
    `added ${savedPerson.name} number ${savedPerson.number} to phonebook`
  );

  mongoose.connection.close();
});

将对象保存到数据库是通过恰当命名的 save 方法实现的,可以通过 then 方法提供一个事件处理程序。

当对象保存到数据库时,将调用提供给该对象的事件处理。事件处理程序使用命令代码 mongoose.connection.close() 关闭数据库连接。 如果连接没有关闭,程序将永远不能完成它的执行。

从数据库中获取对象

Person.find({}).then((result) => {
  console.log("phonebook: ");
  result.forEach((person) => {
    console.log(person.name, person.number);
  });

  mongoose.connection.close();
});

当代码执行时,程序会输出存储在数据库中的所有 persons。

搜索条件遵循 Mongo 搜索查询语法。例:Person.find({ name: "UserX" }).then((result) => {...}

3. 使用 mongoose 实现 CRUD

连接后端到数据库

将 Mongoose 特定的代码提取到它自己的模块中,为模块 models 创建一个新目录,并添加一个名为 person.js 的文件:

const mongoose = require("mongoose");

const url = process.env.MONGODB_URL;

mongoose
  .connect(url)
  .then((result) => {
    console.log("connected to MongoDB");
  })
  .catch((error) => {
    console.log("error connecting to MongoDB:", error.message);
  });

const personSchema = new mongoose.Schema({
  name: String,
  number: String,
});

personSchema.set("toJSON", {
  transform: (document, returnedObject) => {
    returnedObject.id = returnedObject._id.toString();
    delete returnedObject._id;
    delete returnedObject.__v;
  },
});

module.exports = mongoose.model("Person", personSchema);

尽管 Mongoose 对象的 id 属性看起来像一个字符串,但实际上它是一个对象。为了安全起见,我们定义的 toJSON 方法将其转换为字符串。

格式化 Mongoose 返回的对象的一种方法是修改 Schema 的 toJSON 方法,这个 Schema 是作用在所有 models 实例上的。

在 index.js 中导入模块:

const Person = require("./models/person");

定义环境变量

有很多方法可以定义环境变量的值。 一种方法是在应用启动时定义它,一个更复杂的方法是使用dotenv。

npm install dotenv

使用这个库时,创建一个 .env 文件在项目的根部,在文件内定义环境变量。

之后可使用 require('dotenv').config() 命令来使用 .env 文件中定义的环境变量。可以在代码中像引用普通环境变量(process.env.MONGODB_URI)一样引用它们。

index.js:

require("dotenv").config();

const express = require("express");
const cors = require("cors");

const app = express();
const Person = require("./models/person");

...

const PORT = process.env.PORT;
app.listen(PORT, () => {
   console .log(` Server running on port ${PORT}`);
});

在 Route Handlers 中使用数据库实现 CRUD

const app = express();
const Person = require("./models/person");

...

app.get("/api/persons", (req, res) => {
  Person.find({}).then((persons) => res.json(persons));
});

app.get("/api/persons/:id", (req, res, next) => {
  Person.findById(req.params.id)
    .then((person) => {
      if (person) {
        res.json(person);
      } else {
        res.status(404).end();
      }
    })
    .catch((error) => {
      console.log("Person not found");
      next(error);
    });
});

app.delete("/api/persons/:id", (req, res, next) => {
  Person.findByIdAndRemove(req.params.id)
    .then((result) => {
      res.status(204).end();
    })
    .catch((error) => next(error));
});

app.post("/api/persons", (req, res, next) => {
  const body = req.body;

  if (!body.name || !body.number) {
    return res.status(400).json({
      error: "name or number missing",
    });
  }

  const person = new Person({
    name: body.name,
    number: body.number,
  });

  person
    .save()
    .then((savedPerson) => res.json(savedPerson))
    .catch((error) => next(error));
});

app.put("/api/persons/:id", (req, res, next) => {
  const body = req.body;

  const person = {
    name: body.name,
    number: body.number,
  };

  Person.findByIdAndUpdate(
    req.params.id,
    person
  )
    .then((updatedPerson) => {
      res.json(updatedPerson);
    })
    .catch((error) => next(error));
});

4. 使用 Middleware 处理异常

在处理 Promises 时,保持随时添加错误和异常处理(Error Handling)的好习惯。在某些情况下,在一个位置实现所有错误处理是更合理的解决方案。

使用next 函数向下传递错误:

...
.catch(error => next(error))

将向前传递的错误作为参数给 next 函数。 如果在没有参数的情况下调用 next,那么执行将简单地转移到下一个路由或中间件上。 如果使用参数调用next 函数,那么执行将继续到error 处理程序中间件。

Express Error Handlers 是一种中间件(Middleware),它定义了一个接受4个参数的函数。我们的错误处理程序:

const errorHandler = (error, request, response, next) => {
  console.error(error.message);

  if (error.name === "CastError" && error.kind === "ObjectId") {
    return response.status(400).send({ error: "malformatted id" });
  }

  next(error);
};

...

app.use(errorHandler);

中间件的执行顺序与通过 app.use 函数加载到 express 中的顺序相同。 出于这个原因,在定义中间件时一定要小心。处理错误的中间件是最后加载的中间件。

正确的顺序:

app.use(express.static("build"));
app.use(express.json());
app.use(cors());

const errorHandler = (error, request, response, next) => {...};

app.use(errorHandler);

5. 使用 mongoose 的 validation

在数据存储到数据库之前验证数据格式的一个更聪明的方法是使用 Mongoose 提供的 validation 功能。

可以为 model 中的每个字段定义特定的验证规则:

// person.js

var uniqueValidator = require("mongoose-unique-validator");

const personSchema = new mongoose.Schema({
  name: {
    type: String,
    minLength: 3,
    required: true,
    unique: true,
  },
  number: {
    type: String,
    minLength: 8,
    required: true,
  },
});

...
personSchema.plugin(uniqueValidator);

如果尝试在数据库中存储一个不符合规则的对象,操作将引发异常。可以通过错误处理程序来处理这些验证错误:

// index.js

const errorHandler = (error, request, response, next) => {
  console.error(error.message);

  if (error.name === "CastError" && error.kind === "ObjectId") {
    return response.status(400).send({ error: "malformatted id" });
  } else if (error.name === "ValidationError") {
    return response.status(400).json({ error: error.message });
  }

  next(error);
};

...

app.put("/api/persons/:id", (req, res, next) => {
  const body = req.body;

  const person = {
    name: body.name,
    number: body.number,
  };

  Person.findByIdAndUpdate(
    req.params.id,
    person,
    { runValidators: true, context: "query" }
  )
    .then((updatedPerson) => {
      res.json(updatedPerson);
    })
    .catch((error) => next(error));
});

6. 将数据库后端部署到 Heroku

dotenv 中定义的环境变量仅在开发时使用,不处于生产模式。在生产环境中定义数据库 URL 的环境变量应该使用 heroku config:set 命令来设置 Heroku。

$ heroku config:set MONGODB_URI=mongodb+srv://...

如果有什么问题,可以用 heroku log 检查。