koa2 仿知乎笔记
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 的作用
- 响应 options 的方法,告诉它所支持的请求方法
app.use(router.allowedMethods());
加上它,使该接口支持了 options 请求
- 相应地返回 405(不允许)和 501(没实现)
405 是告诉你还没有写该 HTTP 方法
501 是告诉你它还不支持该 HTTP 方法( 比如 Link… )
获取 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
更合理的目录结构
主页
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, 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);
认证之后再授权
【第三方】用户认证与授权 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 --savenpm 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);
使用 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的来源,包括
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 - 指定可以上传所有的图片文件
个人资料的 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; }
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);
话题 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
- 掌握多进程编程知识
- 学习使用日志和性能监控
koa2 仿知乎笔记相关推荐
- Koa2仿知乎服务端项目:Webpack配置
项目简介 该项目为一个后端项目,该项目仿"知乎",模拟实现了: JWT用户认证.授权模块 上传图片模块 个人资料模块 关注与粉丝模块 话题模块 问题模块 答案模块 评论模块 共计4 ...
- Koa 2 基础(仿知乎)
Koa 2 基础 接口文档 Postman仿知乎在线测试 REST 简介 REST是什么 REST是Resource Representational State Transfer的缩写,是一种Web ...
- node.js仿知乎
所需: restful API理论 koa2 mongodb jwt postman 1.restful API的6个限制和若干最佳实践 2.Koa2,Postman,MongoDB,JWT等技术 3 ...
- android仿知乎按钮动效,Android仿知乎客户端关注和取消关注的按钮点击特效实现思路详解...
先说明一下,项目代码已上传至github,不想看长篇大论的也可以先去下代码,对照代码,哪里不懂点哪里. 代码在这https://github.com/zgzczzw/ZHFollowButton 前几 ...
- Android 仿知乎创意广告
代码地址如下: http://www.demodashi.com/demo/14904.html ###一.概述 貌似前段时间刷知乎看到的一种非常有特色的广告展现方式,即在列表页,某一个Item显示背 ...
- canvasnest 移动距离_GitHub - XiaoxinJiang/canvas-nest: 仿知乎登录页面canvas-nest
canvas-nest 仿知乎登录页面canvas-nest 首先上效果图: 因为使用gif图片的原因,线条不是很清晰,大家可以到我的博客观看效果:http://cherryblog.site/ ,( ...
- java开发社交网站_仿知乎问答社交平台网站
zhihu仿知乎问答社交平台简介 这是一个仿知乎的问答社交平台网站,界面与基本功能均仿照知乎.目前实现包括注册,提问,回答,点赞,关注,私信等功能. 技术选型 后端 核心框架:Spring Frame ...
- 仿知乎客户端的白天黑夜主题切换
仿知乎客户端的白天黑夜主题切换 转载请注明出处 作者:AboutJoke ( http://blog.csdn.net/u013200308 ) 原文链接:http://blog.csdn.net/u ...
- 高仿知乎android,Android高仿知乎首页Behavior
Android自定义Behavior实现跟随手势滑动,显示隐藏标题栏.底部导航栏及悬浮按钮 Android Design包下的CoordinatorLayout是相当重要的一个控件,它让许多动画的实现 ...
最新文章
- SQLServer中Case的用法
- int p 和int p
- IDEA中如何设置方法注释格式
- WPF 重要新概念读书笔记(转)
- centos7根据端口查进程_记录一次CentOs7下Nginx+WSGI部署Django项目(超详细)
- 2017年Spring发布了30个新的Android库,值得您关注
- mysql 自定义序列号_在mysql中怎样设置,才能自动添加序列号
- Java如何实现后端分页
- netty的channel介绍
- React入门基础+练习(二)
- 揭秘2017双11背后的网络-双11的网络产品和技术概览
- 通过angularjs的directive以及service来实现的列表页加载排序分页
- 费率转换成利率的计算器_存款利率计算器
- Proteus仿真:简易独立式键盘
- linux安装CAS认证服务器
- Python将base64编码转换为图片并存储
- 深圳腾讯地图地铁站经纬度
- JasperReport导出Excel锁定行或列
- 台当局死磕美国Uber
- dp 最佳加法表达式
热门文章
- hikari数据源配置类_SpringBoot多数据源配置详解
- java确认rabbitmq_RabbitMQ的消息确认模式
- centos7 mysql 5.5.27_centos7上安装mysql-5.7.27
- 渗透测试入门27之渗透测试学习建议
- 计算机能帮助我学英语翻译,英语翻译以下几个句子,帮忙把汉语翻译成英语,请不要用软件翻!1、计算机能帮助人们从事复杂的计算.几十年前可能需要数月完成...
- double 保留两位小数
- nginx 4层代理配置
- day3—python——字符串常用方法
- Eclipse创建SpringMVC,Spring, Hibernate项目
- [转载]手工安全测试方法修改建议