koa2 仿知乎筆記

碼小余の部落格發表於2020-12-17

Koa2 仿知乎筆記

路由

普通路由

const Router = require("koa-router")
const router = new Router()

router.get("/", (ctx) => {
    ctx.body = "這是主頁"
})
router.get("/user", (ctx) => {
    ctx.body = "這是使用者列表"
})

app.use(router.routes());

ctx.body 可以渲染頁面, 也可以是返回的資料內容

字首路由

const Router = require("koa-router")
const userRouter = new Router({ prefix: "/users" })

userRouter.get("/", (ctx) => {
    ctx.body = "這是使用者列表"
})

app.use(userRouter.routes());

使用的是 prefix 字首,簡化路由的書寫

HTTP options 方法

主要作用就是檢查一下某介面支援哪些 HTTP 方法

allowedMethods 的作用

  1. 響應 options 的方法,告訴它所支援的請求方法
app.use(router.allowedMethods());

加上它,使該介面支援了 options 請求

image-20201213094921406

  1. 相應地返回 405(不允許)和 501(沒實現)

405 是告訴你還沒有寫該 HTTP 方法

image-20201213095525484

501 是告訴你它還不支援該 HTTP 方法( 比如 Link… )

image-20201213095553895

獲取 HTTP 請求引數

獲取 ? 後面的值

ctx.query

獲取 路由 引數

ctx.params.id

獲取 body 引數

這個需要安裝第三方中介軟體 koa-bodyparser

npm i koa-bodyparser --save

使用 koa-bodyparser

const bodyparser = require("koa-bodyparser")

app.use(bodyparser())

然後再獲取

ctx.request.body

獲取 header

ctx.header 或者 ctx.headers

更合理的目錄結構

image-20201213172804097

主頁

  • app/index.js

    const Koa = require("koa");
    const bodyparser = require("koa-bodyparser");
    const app = new Koa();
    const routing = require("./routes");
    
    app.use(bodyparser());
    routing(app);
    
    app.listen(3000, () => console.log("服務啟動成功 - 3000"));
    

路由

  • app/routes/home.js

    const Router = require("koa-router");
    const router = new Router();
    const { index } = require("../controllers/home");
    
    router.get("/", index);
    
    module.exports = router;
    

    這裡傳入類方法作為 router 的回撥函式

  • app/routes/users.js

    const Router = require("koa-router");
    const router = new Router({ prefix: "/users" });
    const {
      find,
      findById,
      create,
      update,
      delete: del,
    } = require("../controllers/users");
    
    const db = [{ name: "李雷" }];
    
    // 獲取使用者列表 - get
    router.get("/", find);
    
    // 獲取指定使用者 - get
    router.get("/:id", findById);
    
    // 新增使用者 - post
    router.post("/", create);
    
    // 修改使用者 - put
    router.put("/:id", update);
    
    // 刪除使用者 - delete
    router.delete("/:id", del);
    
    module.exports = router;
    
  • app/routes/index.js

    const fs = require("fs");
    module.exports = (app) => {
      // console.log(fs.readdirSync(__dirname));
      fs.readdirSync(__dirname).forEach((file) => {
        if (file === "index.js") return;
        const route = require(`./${file}`);
        app.use(route.routes()).use(route.allowedMethods());
      });
    };
    

    這裡把 app.use 的寫法封裝起來簡化

控制器

  • controllers/home.js

    class HomeCtl {
      index(ctx) {
        ctx.body = "這是主頁";
      }
    }
    
    module.exports = new HomeCtl();
    

    使用類和類方法的方法把具體邏輯封裝到控制器中

  • controllers/users.js

    const db = [{ name: "李雷" }];
    
    class UserCtl {
      find(ctx) {
        ctx.body = db;
      }
      findById(ctx) {
        ctx.body = db[ctx.params.id * 1];
      }
      create(ctx) {
        db.push(ctx.request.body);
        // 返回新增的使用者
        ctx.body = ctx.request.body;
      }
      update(ctx) {
        db[ctx.params.id * 1] = ctx.request.body;
        ctx.body = ctx.request.body;
      }
      delete(ctx) {
        db.splice(ctx.params.id * 1, 1);
        ctx.status = 204;
      }
    }
    
    module.exports = new UserCtl();
    

自定義防報錯中介軟體

  • app/index.js

    app.use(async (ctx, next) => {
      try {
        await next();
      } catch (err) {
        ctx.status = err.status || err.statusCode || 500;
        ctx.body = {
          message: err.message,
        };
      }
    });
    

    此中介軟體會丟擲自定義的錯誤和執行時錯誤和伺服器內部錯誤

    但是不能丟擲 404 錯誤

  • app/controllers/users.js

    class UserCtl {
      // ...
      findById(ctx) {
        if (ctx.params.id * 1 >= db.length) {
          ctx.throw(412, "先決條件失敗: id 大於等於陣列長度了");
        }
        ctx.body = db[ctx.params.id * 1];
      }
      // ...
    }
    

    自定義錯誤如上,當使用者輸入的 id 值超出 db 的長度時,會主動丟擲 412 錯誤

使用 koa-json-error

koa-json-error 是一個非常強大的錯誤處理第三方中介軟體,可以處理 404 錯誤,返回堆疊資訊等等

在生產環境中不能返回堆疊資訊,在開發環境中需要返回堆疊資訊

安裝

npm i koa-json-error --save

使用

app.use(error({
    postFormat: (e, { stack, ...rest }) => process.env.NODE_ENV === "production" ? rest : { stack, ...rest }
}))

以上程式碼不需要理解,複製即可

process.env.NODE_ENV - 獲取環境變數

production - 代表生產環境

因為需要判斷是否是生產環境,所以還需要更改 package.json 檔案

  • windows

    需要安裝 cross-env

    npm i cross-env --save-dev
    
    "scripts": {
        "start": "cross-env NODE_ENV=production node app",
        "dev": "nodemon app"
    },
    
  • mac

    "scripts": {
        "start": "NODE_ENV=production node app",
        "dev": "nodemon app"
    },
    

koa-parameter 校驗請求引數

安裝 koa-parameter

npm i koa-parameter --save

使用 koa-parameter

const parameter = require("koa-parameter");

app.use(parameter(app));

在更新和刪除時需要驗證

  • app/controllers.users.js

    create(ctx) {
        // 請求引數驗證
        ctx.verifyParams({
            name: { type: "string", required: true },
            age: { type: "number", required: false },
        });
        // ...
    }
    
    update(ctx) {
        // ...
        ctx.verifyParams({
            name: { type: "string", required: true },
            age: { type: "number", required: false },
        });
        // ...
    }
    

為什麼要用 NoSQL ?

  • 簡單(沒有原子性、一致性、隔離性等複雜規範)
  • 便於橫向擴充
  • 適合超大規模資料的儲存
  • 很靈活地儲存複雜結構的資料(Schema Free)

雲 MongoDB

  • 阿里雲、騰訊雲(收費)
  • MongoDB 官方的 MongoDB Atlas(免費 + 收費)

使用 mongoose 連線 雲 MongoDB

npm i mongoose
  • app/config.js

    module.exports = {
      connectionStr:
        "mongodb+srv://maxiaoyu:<password>@zhihu.irwgy.mongodb.net/<dbname>?retryWrites=true&w=majority",
    };
    

    password 為你在 雲MongoDB 中 Database User 密碼

    dbname 為你 Cluster 中的資料庫名字

  • app/index.js

    const mongoose = require("mongoose");
    
    const { connectionStr } = require("./config");
    
    mongoose.connect(
      connectionStr,
      { useNewUrlParser: true, useUnifiedTopology: true },
      () => console.log("MongoDB 連線成功了!")
    );
    mongoose.connection.on("error", console.error);
    

設計使用者模組的 Schema

在 app 下新建 models 資料夾,裡面寫所有的 Schema 模型

  • app/models/users.js

    const mongoose = require("mongoose");
    
    const { Schema, model } = mongoose;
    
    const userSchema = new Schema({
      name: { type: String, required: true },
    });
    
    module.exports = model("User", userSchema);
    

    model 的第一個引數 User 是將要生成的 集合名稱

    第二個引數為 Schema 的例項物件,其中定義了資料的型別等

實現使用者註冊

  • app/models/users.js

    const mongoose = require("mongoose");
    
    const { Schema, model } = mongoose;
    
    const userSchema = new Schema({
      __v: { type: String, select: false },
      name: { type: String, required: true },
      password: { type: String, required: true, select: false },
    });
    
    module.exports = model("User", userSchema);
    

    select - 是否在查詢時顯示該欄位

  • app/controllers/users.js

    async create(ctx) {
        // 請求引數驗證
        ctx.verifyParams({
            name: { type: "string", required: true },
            password: { type: "string", required: true },
        });
        const { name } = ctx.request.body;
        const repeatedUser = await User.findOne({ name });
        if (repeatedUser) ctx.throw(409, "使用者已經佔用");
        const user = await new User(ctx.request.body).save();
        // 返回新增的使用者
        ctx.body = user;
    }
    

    409 錯誤,表示使用者已經佔用

實現使用者登入

  • app/controllers/users.js

    async login(ctx) {
        ctx.verifyParams({
            username: { type: "string", required: true },
            password: { type: "string", required: true },
        });
        const user = await User.findOne(ctx.request.body);
        if (!user) ctx.throw(401, "使用者名稱或密碼不正確");
        const { _id, name } = user;
        const token = jsonwebtoken.sign({ _id, name }, secret, { expiresIn: "1d" });
        ctx.body = { token };
    }
    

    登入需要返回 token,可以使用第三方中介軟體 jsonwebtoken ,簡稱 JWT

    npm i jsonwebtoken --save
    

    secret - 為 token 密碼

    expiresIn - 為過期時間

【自己編寫】使用者認證與授權

使用者登入後返回 token ,從 token 中獲取使用者資訊

  • app/routes/users.js

    // 使用者認證中介軟體
    const auth = async (ctx, next) => {
      const { authorization = "" } = ctx.request.header;
      const token = authorization.replace("Bearer ", "");
      try {
        const user = jsonwebtoken.verify(token, secret);
        ctx.state.user = user;
      } catch (error) {
        ctx.throw(401, error.message);
      }
      await next();
    };
    

    verify - 認證 token,然後解密 token,獲取到使用者資訊,將使用者資訊儲存到 ctx.state.user 中

    await next() - 使用者認證通過後進行下一步

使用者認證通過後,進行使用者授權

例如:李雷不能修改韓梅梅的資訊,韓梅梅也不能修改李雷的資訊

  • app/controllers/users.js

    async checkOwner(ctx, next) {
        if (ctx.params.id !== ctx.state.user._id) ctx.throw(403, "沒有許可權");
        await next();
    }
    
  • 使用(app/routes/users.js)

    // 修改使用者 - patch
    router.patch("/:id", auth, checkOwner, update);
    
    // 刪除使用者 - delete
    router.delete("/:id", auth, checkOwner, del);
    

    認證之後再授權

【第三方】使用者認證與授權 koa-jwt

安裝

npm i koa-jwt --save

使用

  • app/routes/users.js
const jwt = require("koa-jwt");

const auth = jwt({ secret });

把 auth 更改一下即可

koa-jwt 內部同樣把 user 儲存到了 ctx.state.user 中,並且有 await next()

使用 koa-body 中介軟體獲取上傳的檔案

koa-body 替換 koa-bodyparser

npm i koa-body --save

npm uninstall koa-bodyparser --save

使用

  • app/index.js

    const koaBody = require("koa-body");
    
    app.use(
      koaBody({
        multipart: true, // 代表圖片格式
        formidable: {
          uploadDir: path.join(__dirname, "/public/upload"), // 指定檔案存放路徑
          keepExtensions: true, // 保留副檔名
        },
      })
    );
    

    這樣寫就可以在請求上傳圖片的介面時上傳圖片了

  • app/controllers/home.js

    upload(ctx) {
        const file = ctx.request.files.file;
        // console.log(file);
        ctx.body = { path: file.path };
    }
    

    file 為上傳檔案時的那個引數名

    file.path 可以獲取到該圖片上傳好之後的絕對路徑,既然已是絕對路徑,那就必然不可,後面將會提供 轉成 http 路徑的方法

  • app/routes/home.js

    const { index, upload } = require("../controllers/home");
    
    router.post("/upload", upload);
    

使用 koa-static 生成圖片連結

安裝

npm i koa-static --save

使用

const koaStatic = require("koa-static");

app.use(koaStatic(path.join(__dirname, "public")));
  • app/controllers/home.js

    upload(ctx) {
        const file = ctx.request.files.file;
        const basename = path.basename(file.path);
        ctx.body = { url: `${ctx.origin}/uploads/${basename}` };
    }
    

    path.basename(絕對路徑) - 獲取基礎路徑

    ctx.origin - 獲取URL的來源,包括 protocolhost

前端上傳圖片

<form action="/upload" enctype="multipart/form-data" method="POST">
    <input type="file" name="file" accept="image/*" />
    <button type="submit">上傳</button>
</form>

action - 上傳的介面

enctype - 指定上傳檔案

type - 檔案型別 name - 上傳的引數名 accept - 指定可以上傳所有的圖片檔案

個人資料的 schema 設計

  • app/models/users.js

    const userSchema = new Schema({
      __v: { type: String, select: false },
      username: { type: String, required: true },
      password: { type: String, required: true, select: false },
      avatar_url: { type: String },
      gender: {
        type: String,
        enum: ["male", "female"],
        default: "male",
        required: true,
      },
      headline: { type: String },
      locations: { type: [{ type: String }] },
      business: { type: String },
      employments: {
        type: [
          {
            company: { type: String },
            job: { type: String },
          },
        ],
      },
      educations: {
        type: [
          {
            school: { type: String },
            major: { type: String },
            diploma: { type: Number, enum: [1, 2, 3, 4, 5] },
            entrance_year: { type: Number },
            graduation_year: { type: Number },
          },
        ],
      },
    });
    

    注意:type: [] - 代表陣列型別

    enum - 為列舉的資料

    default - 為預設值

欄位過濾

把一些不需要返回的欄位都加上 select: false 實現了欄位隱藏

然後通過 fields 來查詢指定的引數

async findById(ctx) {
    const { fields = "" } = ctx.query;
    const selectFields = fields
    .split(";")
    .filter((f) => f)
    .map((f) => " +" + f)
    .join("");
    const user = await User.findById(ctx.params.id).select(selectFields);
    if (!user) ctx.throw(404, "使用者不存在");
    ctx.body = user;
}

split - 把 fileds 的值按 ; 分割成陣列

filter - 把空值過濾掉

map - 改變 f 的值,+ 號前必須加上空格

最後用 join(""), 把這個陣列中的每個值連線成一個字串

把這個值傳入 select() 函式中即可使用

關注與粉絲的 Schema 設計

  • app/models/users.js

    following: {
        type: [{ type: Schema.Types.ObjectId, ref: "User" }],
            select: false,
    },
    

    這是 mongoose 中的一種模式型別 ,它用主鍵,而 ref 表示通過使用該主鍵儲存對 User 模型的文件的引用

關注與粉絲介面

獲取某人的關注列表/關注某人

  • app/controllers/users.js

    async listFollowing(ctx) {
        const user = await User.findById(ctx.params.id)
        .select("+following")
        .populate("following");
    
        if (!user) ctx.throw(404, "使用者不存在");
        ctx.body = user.following;
    }
    async follow(ctx) {
        const me = await User.findById(ctx.state.user._id).select("+following");
        if (!me.following.map((id) => id.toString()).includes(ctx.params.id)) {
            me.following.push(ctx.params.id);
            me.save();
        } else {
            ctx.throw(409, "您已關注該使用者");
        }
        ctx.status = 204;
    }
    
    1. populate - 代表獲取該主鍵對應的集合資料,由於 following 主鍵對應的集合為 User ,所以可以獲取到 User 中的資料,從而某人的關注列表的詳細資訊

    2. 由於 following 中的主鍵 id 為 object 型別(可以自行測試),所以需要使用 map 把陣列中的每一項都轉換為 字串 型別,因為這樣才能使用 includes 這個方法來判斷這個 me.following 陣列是否已經包含了你要關注的使用者

  • 介面設計(app/routes/users.js)

    const {
      // ...
      listFollowing,
      follow,
    } = require("../controllers/users");
    
    // 獲取某人的關注列表
    router.get("/:id/following", listFollowing);
    
    // 關注某人
    router.put("/following/:id", auth, follow);
    

    關注某人是在當前登入使用者關注某人,所以需要登入認證

獲取某人的粉絲列表

  • app/controllers/users.js

    async listFollowers(ctx) {
        const users = await User.find({ following: ctx.params.id });
        ctx.body = users;
    }
    

    following: ctx.params.id - 從 following 中找到包含 查詢的 id 的使用者

  • app/routes/users.js

    const {
      // ...
      listFollowers,
    } = require("../controllers/users");
    
    // 獲取某人的粉絲列表
    router.get("/:id/followers", listFollowers);
    

編寫校驗使用者存在與否的中介軟體

  • app/controllers/users.js

    async checkUserExist(ctx, next) {
        const user = await User.findById(ctx.params.id);
        if (!user) ctx.throw(404, "使用者不存在");
        await next();
    }
    
  • 使用(app/routes/users.js)

    // 關注某人
    router.put("/following/:id", auth, checkUserExist, follow);
    
    // 取消關注某人
    router.delete("/following/:id", auth, checkUserExist, unfollow);
    

話題 Schema 設計與使用者 Schema 改造

  • app/models/topics.js

    const mongoose = require("mongoose");
    
    const { Schema, model } = mongoose;
    
    const topicSchema = new Schema({
      __v: { type: String, select: false },
      name: { type: String, required: true },
      avatar_url: { type: String },
      introduction: { type: String, select: false },
    });
    
    module.exports = model("Topic", topicSchema);
    
  • app/models/users.js

    const mongoose = require("mongoose");
    
    const { Schema, model } = mongoose;
    
    const userSchema = new Schema({
      __v: { type: String, select: false },
      username: { type: String, required: true },
      password: { type: String, required: true, select: false },
      avatar_url: { type: String },
      gender: {
        type: String,
        enum: ["male", "female"],
        default: "male",
        required: true,
      },
      headline: { type: String },
      locations: {
        type: [{ type: Schema.Types.ObjectId, ref: "Topic" }],
        select: false,
      },
      business: { type: Schema.Types.ObjectId, ref: "Topic", select: false },
      employments: {
        type: [
          {
            company: { type: Schema.Types.ObjectId, ref: "Topic" },
            job: { type: Schema.Types.ObjectId, ref: "Topic" },
          },
        ],
        select: false,
      },
      educations: {
        type: [
          {
            school: { type: Schema.Types.ObjectId, ref: "Topic" },
            major: { type: Schema.Types.ObjectId, ref: "Topic" },
            diploma: { type: Number, enum: [1, 2, 3, 4, 5] },
            entrance_year: { type: Number },
            graduation_year: { type: Number },
          },
        ],
        select: false,
      },
      following: {
        type: [{ type: Schema.Types.ObjectId, ref: "User" }],
        select: false,
      },
    });
    
    module.exports = model("User", userSchema);
    

    將 locations、business、employments 及 educations 中的 school、major 都通過外來鍵(ref)關聯到 Topic 集合,至於為什麼是關聯 Topic 集合,因為它們都需要返回 Topic 集合中的資料,仔細看程式碼還會發現,following 是 User 自己關聯的自己,因為它需要返回 User 自身的資料

問題 Schema 設計

  • app/models/questions.js

    const mongoose = require("mongoose");
    
    const { Schema, model } = mongoose;
    
    const questionSchema = new Schema({
      __v: { type: String, select: false },
      title: { type: String, required: true },
      description: { type: String },
      questioner: { type: Schema.Types.ObjectId, ref: "User", select: false },
    });
    
    module.exports = model("Question", questionSchema);
    

    questioner - 提問者,每個問題只有一個提問者,每個提問者有多個問題

  • 模糊搜尋 title 或 description

    async find(ctx) {
        const { per_page = 10 } = ctx.query;
        const page = Math.max(ctx.query.page * 1, 1);
        const perPage = Math.max(per_page * 1, 1);
        const q = new RegExp(ctx.query.q);
        const question = await Question.find({$or: [{title: q}, {description: q}]})
        .limit(perPage)
        .skip((page - 1) * perPage);
        ctx.body = question;
    }
    

    new RegExp(ctx.query.q) - 模糊搜尋包含 ctx.query.q 的問題

    $or - 既能匹配 title, 也能匹配 description

進階

一個問題下有多個話題(限制)

一個話題下也可以有多個問題(無限)

所以在設計 Schema 時應該把有限的資料放在無限的資料裡,防止了資料庫爆破

  • app/models/questions.js

    const questionSchema = new Schema({
      __v: { type: String, select: false },
      title: { type: String, required: true },
      description: { type: String },
      questioner: { type: Schema.Types.ObjectId, ref: "User", select: false },
      topics: {
        type: [{ type: Schema.Types.ObjectId, ref: "Topic" }],
        select: false,
      },
    });
    

    把 topics 放在了問題裡面

  • 可以直接很簡單的獲取到 topics 了(app/controllers/questions.js)

    async findById(ctx) {
        const { fields = "" } = ctx.query;
        const selectFields = fields
        .split(";")
        .filter((f) => f)
        .map((f) => " +" + f)
        .join("");
        const question = await Question.findById(ctx.params.id)
        .select(selectFields)
        .populate("questioner topics");
        ctx.body = question;
    }
    

    直接在 populate 中新增上 topics 即可

  • 在話題控制器中可以通過查詢指定的話題來獲取多個問題(app/controllers/topics.js)

    async listQuestions(ctx) {
        const questions = await Question.find({ topics: ctx.params.id });
        ctx.body = questions;
    }
    

    查詢出 Question 下的 topics 中包含當前查詢的話題 id 的所有問題

  • app/routes/topics.js

    const {
      // ...
      listQuestions,
    } = require("../controllers/topics");
    
    // 獲取某個話題的問題列表
    router.get("/:id/questions", checkTopicExist, listQuestions);
    

    設計獲取某個話題的問題列表的介面

互斥關係的贊踩答案介面設計

  • app/models/users.js

    likingAnswer: {
        type: [{ type: Schema.Types.ObjectId, ref: "Answer" }],
        select: false,
    },
    dislikingAnswer: {
        type: [{ type: Schema.Types.ObjectId, ref: "Answer" }],
        select: false,
    },
    

    贊 / 踩模型設計

  • 控制器

    主要需要注意的就是以下 mongoose 語法

    $inc: { 需要增加的欄位名: 需要增加的數字值 }
    
  • 介面設計(app/routes/users.js)

    // 獲取某使用者的回答點贊列表
    router.get("/:id/likingAnswers", listLikingAnswers);
    
    // 贊答案(讚了之後取消踩)
    router.put(
      "/likingAnswer/:id",
      auth,
      checkAnswerExist,
      likingAnswer,
      undislikingAnswer
    );
    
    // 取消贊答案
    router.put("/unlikingAnswer/:id", auth, checkAnswerExist, unlikingAnswer);
    
    // 獲取某使用者的踩答案列表
    router.get("/:id/disLikingAnswers", listDisLikingAnswers);
    
    // 踩答案(踩了之後取消贊)
    router.put(
      "/dislikingAnswer/:id",
      auth,
      checkAnswerExist,
      dislikingAnswer,
      unlikingAnswer
    );
    
    // 取消踩答案
    router.put("/undislikingAnswer/:id", auth, checkAnswerExist, undislikingAnswer);
    

    贊踩互斥主要就是通過在這裡寫的,讚了之後取消踩,踩了之後取消贊

二級評論 Schema 設計

  • app/models/comments.js

    const commentSchema = new Schema({
      __v: { type: String, select: false },
      content: { type: String, required: true },
      commentator: { type: Schema.Types.ObjectId, ref: "User", select: false },
      questionId: { type: String, required: true },
      answerId: { type: String, required: true },
      rootCommentId: { type: String },
      replyTo: { type: Schema.Types.ObjectId, ref: "User" },
    });
    

    其實就是在一級評論的基礎上新增了兩行程式碼就實現了二級評論,並且是一級評論和二級評論共用一介面

    rootCommentId - 根評論 Id, 也就是你要回復的評論 id

    replyTo - 回覆評論的使用者,此欄位為主鍵,直接關聯 User 集合

  • app/controllers/comments.js

    const Comment = require("../models/comments");
    
    class UserCtl {
      async find(ctx) {
        const { per_page = 10 } = ctx.query;
        const page = Math.max(ctx.query.page * 1, 1);
        const perPage = Math.max(per_page * 1, 1);
        const q = new RegExp(ctx.query.q);
        const { questionId, answerId } = ctx.params;
        const { rootCommentId } = ctx.query;
        const comment = await Comment.find({
          content: q,
          questionId,
          answerId,
          rootCommentId,
        })
          .limit(perPage)
          .skip((page - 1) * perPage)
          .populate("commentator replyTo");
        ctx.body = comment;
      }
      async checkCommentExist(ctx, next) {
        const comment = await Comment.findById(ctx.params.id).select(
          "+commentator"
        );
        ctx.state.comment = comment;
        if (!comment) ctx.throw(404, "評論不存在");
        // 只有刪改查答案時才檢查此邏輯,贊、踩答案時不檢查
        if (ctx.params.questionId && comment.questionId !== ctx.params.questionId)
          ctx.throw(404, "該問題下沒有此評論");
        if (ctx.params.answerId && comment.answerId !== ctx.params.answerId)
          ctx.throw(404, "該答案下沒有此評論");
        await next();
      }
      async findById(ctx) {
        const { fields = "" } = ctx.query;
        const selectFields = fields
          .split(";")
          .filter((f) => f)
          .map((f) => " +" + f)
          .join("");
        const comment = await Comment.findById(ctx.params.id)
          .select(selectFields)
          .populate("commentator");
        ctx.body = comment;
      }
      async create(ctx) {
        // 請求引數驗證
        ctx.verifyParams({
          content: { type: "string", required: true },
          rootCommentId: { type: "string", required: false },
          replyTo: { type: "string", required: false },
        });
        const commentator = ctx.state.user._id;
        const { questionId, answerId } = ctx.params;
        const comment = await new Comment({
          ...ctx.request.body,
          commentator,
          questionId,
          answerId,
        }).save();
        // 返回新增的話題
        ctx.body = comment;
      }
      async checkCommentator(ctx, next) {
        const { comment } = ctx.state;
        if (comment.commentator.toString() !== ctx.state.user._id)
          ctx.throw(403, "沒有許可權");
        await next();
      }
      async update(ctx) {
        ctx.verifyParams({
          content: { type: "string", required: false },
        });
        const { content } = ctx.request.body;
        await ctx.state.comment.update(content);
        ctx.body = ctx.state.comment;
      }
      async delete(ctx) {
        await Comment.findByIdAndRemove(ctx.params.id);
        ctx.status = 204;
      }
    }
    
    module.exports = new UserCtl();
    

    這是評論控制器

    首先是 find

    • 實現了是查詢一級評論還是查詢二級評論的功能 - const { rootCommentId } = ctx.query; 在請求時你不寫這個 rootCommentId 引數即是查詢一級評論,寫了則是查詢二級評論
    • 另外在 populate 中接收了評論者(commentator)和回覆者(replyTo)

    然後是檢查評論是否存在

    • 如果評論不存在則做出相應的提示
    • 如果評論存在,則直接放行

    然後是根據 評論id 查詢評論

    • 這個就是單純的查詢一條評論了,因為 populate 中返回了 commentator,所以不會返回 replyTo
    • 需要注意的是,如果返回結果中沒有 rootCommentId, 則該條評論為一級評論,如果有 rootCommentId,則該條評論為二級評論

    然後是新增評論

    • 其中有 content 引數,為必選引數,如果在新增評論時只寫了該引數,則會新增一級評論
    • 還有 rootCommentId、replyTo 兩個可選引數,如果寫上這倆,則會新增二級評論

    然後是檢查評論者是不是自己

    • 如果不是自己,則無法修改評論和刪除評論

    然後就是修改評論

    • 只能修改評論內容(content),而不能修改當前評論回覆的那個評論的 id(rootCommentId) 和評論者(replyTo),否則就會導致驢脣不對馬嘴的結果!

    最後就是刪除評論

    • 刪除當前評論,回覆的評論不會被刪除

mongoose 如何優雅的加上日期

只需一行程式碼

在 Schema 的第二個引數中加上 { timestamps: true } 即可

例如:

const commentSchema = new Schema(
  {
    __v: { type: String, select: false },
    content: { type: String, required: true },
    commentator: { type: Schema.Types.ObjectId, ref: "User", select: false },
    questionId: { type: String, required: true },
    answerId: { type: String, required: true },
    rootCommentId: { type: String },
    replyTo: { type: Schema.Types.ObjectId, ref: "User" },
  },
  { timestamps: true }
);

mongoose 如何返回更新後的資料

要實現這個需要使用 findByIdAndUpdate 配合 {new: true} 來完成

具體用法如下:

const comment = await Comment.findByIdAndUpdate(
    ctx.params.id,
    { content },
    { new: true }
);

總結

RESTful API 設計參考 GitHub API v3

v3 版本可謂是 API 的教科書

使用 GitHub 搜尋 Koa2 資源

使用 Stack Overflow 搜尋問題

比如說你不知道如何用 MongoDb 設計關注與粉絲的表結構,你就可以使用 Stack Overflow 來搜尋這個問題(不過記得要翻譯成英文)

  • 擴充建議
    • 使用企業級 Node.js 框架 —— Egg.js
    • 掌握多程式程式設計知識
    • 學習使用日誌和效能監控

數,則會新增一級評論

  • 還有 rootCommentId、replyTo 兩個可選引數,如果寫上這倆,則會新增二級評論

然後是檢查評論者是不是自己

  • 如果不是自己,則無法修改評論和刪除評論

然後就是修改評論

  • 只能修改評論內容(content),而不能修改當前評論回覆的那個評論的 id(rootCommentId) 和評論者(replyTo),否則就會導致驢脣不對馬嘴的結果!

最後就是刪除評論

  • 刪除當前評論,回覆的評論不會被刪除

相關文章