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 方法
app.use(router.allowedMethods());
加上它,使该接口支持了 options 请求
405 是告诉你还没有写该 HTTP 方法
501 是告诉你它还不支持该 HTTP 方法( 比如 Link… )
ctx.query
ctx.params.id
这个需要安装第三方中间件
koa-bodyparser
npm i koa-bodyparser --save
使用
koa-bodyparser
const bodyparser = require("koa-bodyparser")
app.use(bodyparser())
然后再获取
ctx.request.body
ctx.header 或者 ctx.headers
主页
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 是一个非常强大的错误处理第三方中间件,可以处理 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"
},
npm i koa-parameter --save
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 },
});
// ...
}
npm i mongoose
app/config.js
module.exports = {
connectionStr:
"mongodb+srv://maxiaoyu:@zhihu.irwgy.mongodb.net/?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);
在 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, username } = user;
const token = jsonwebtoken.sign({
_id, username }, secret, {
expiresIn: "1d" });
ctx.body = {
token };
}
登录需要返回 token,可以使用第三方中间件
jsonwebtoken
,简称 JWTnpm 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);
认证之后再授权
npm i koa-jwt --save
const jwt = require("koa-jwt");
const auth = jwt({
secret });
把 auth 更改一下即可
koa-jwt 内部同样把 user 保存到了 ctx.state.user 中,并且有 await next()
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/uploads"), // 指定文件存放路径
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);
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的来源,包括
protocol
和host
。
<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 - 指定可以上传所有的图片文件
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() 函数中即可使用
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;
}
populate - 代表获取该主键对应的集合数据,由于 following 主键对应的集合为 User ,所以可以获取到 User 中的数据,从而某人的关注列表的详细信息
由于 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);
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 自身的数据
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);
赞踩互斥主要就是通过在这里写的,赞了之后取消踩,踩了之后取消赞
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),否则就会导致驴唇不对马嘴的结果!
最后就是删除评论
- 删除当前评论,回复的评论不会被删除
只需一行代码
在 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 }
);
要实现这个需要使用
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 来搜索这个问题(不过记得要翻译成英文)