[
  {
    "path": ".editorconfig",
    "content": "# editorconfig.org\nroot = true\n\n[*]\nindent_style = space\nindent_size = 2\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newline = true\ntab_width = 2\n\n[*.md]\ntrim_trailing_whitespace = false\n\n[Makefile]\nindent_style = tab\n"
  },
  {
    "path": ".eslintrc.json",
    "content": "{\n  \"extends\": \"standard\",\n  \"globals\": {\n    \"describe\": true,\n    \"beforeEach\": true,\n    \"afterEach\": true,\n    \"after\": true,\n    \"it\": true\n  }\n}\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE.md",
    "content": "提问方式：\n\n1. 请先从错误栈自己定位问题，尝试亲自解决问题\n2. 解决不了再去历史 isuue 里查看是否有相似的问题\n3. 最后，提交新的 issue，并将错误代码提交到你的 GitHub，我抽空会帮你调试\n\n不好的提问方式：\n\n1. 只有标题，没有描述\n2. 描述不清楚"
  },
  {
    "path": ".gitignore",
    "content": "config/*\n!config/default.*\nnpm-debug.log\nnode_modules\ncoverage"
  },
  {
    "path": "README.md",
    "content": "## N-blog\n\n使用 Express + MongoDB 搭建多人博客\n\n## 开发环境\n\n- Node.js: `8.9.1`\n- MongoDB: `3.4.10`\n- Express: `4.16.2`\n\n## 目录\n\n- 开发环境搭建\n    - [Node.js 的安装与使用](https://github.com/nswbmw/N-blog/blob/master/book/1.1%20Node.js%20%E7%9A%84%E5%AE%89%E8%A3%85%E4%B8%8E%E4%BD%BF%E7%94%A8.md)\n        - [安装 Node.js](https://github.com/nswbmw/N-blog/blob/master/book/1.1%20Node.js%20%E7%9A%84%E5%AE%89%E8%A3%85%E4%B8%8E%E4%BD%BF%E7%94%A8.md#111-安装-nodejs)\n        - [n 和 nvm](https://github.com/nswbmw/N-blog/blob/master/book/1.1%20Node.js%20%E7%9A%84%E5%AE%89%E8%A3%85%E4%B8%8E%E4%BD%BF%E7%94%A8.md#112-n-和-nvm)\n        - [nrm](https://github.com/nswbmw/N-blog/blob/master/book/1.1%20Node.js%20%E7%9A%84%E5%AE%89%E8%A3%85%E4%B8%8E%E4%BD%BF%E7%94%A8.md#113-nrm)\n    - [MongoDB 的安装与使用](https://github.com/nswbmw/N-blog/blob/master/book/1.2%20MongoDB%20%E7%9A%84%E5%AE%89%E8%A3%85%E4%B8%8E%E4%BD%BF%E7%94%A8.md)\n        - [安装与启动 MongoDB](https://github.com/nswbmw/N-blog/blob/master/book/1.2%20MongoDB%20%E7%9A%84%E5%AE%89%E8%A3%85%E4%B8%8E%E4%BD%BF%E7%94%A8.md#121-安装与启动-mongodb)\n        - [Robomongo 和 MongoChef](https://github.com/nswbmw/N-blog/blob/master/book/1.2%20MongoDB%20%E7%9A%84%E5%AE%89%E8%A3%85%E4%B8%8E%E4%BD%BF%E7%94%A8.md#122-robomongo-和-mongochef)\n- Node.js 知识点讲解\n    - [require](https://github.com/nswbmw/N-blog/blob/master/book/2.1%20require.md)\n    - [exports 和 module.exports](https://github.com/nswbmw/N-blog/blob/master/book/2.2%20exports%20%E5%92%8C%20module.exports.md)\n    - [Promise](https://github.com/nswbmw/N-blog/blob/master/book/2.3%20Promise.md)\n    - [环境变量](https://github.com/nswbmw/N-blog/blob/master/book/2.4%20%E7%8E%AF%E5%A2%83%E5%8F%98%E9%87%8F.md)\n    - [packge.json](https://github.com/nswbmw/N-blog/blob/master/book/2.5%20package.json.md)\n        - [semver](https://github.com/nswbmw/N-blog/blob/master/book/2.5%20package.json.md#251-semver)\n    - [npm 使用注意事项](https://github.com/nswbmw/N-blog/blob/master/book/2.6%20npm%20%E4%BD%BF%E7%94%A8%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A1%B9.md)\n        - [npm init](https://github.com/nswbmw/N-blog/blob/master/book/2.6%20npm%20%E4%BD%BF%E7%94%A8%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A1%B9.md#261-npm-init)\n        - [npm install](https://github.com/nswbmw/N-blog/blob/master/book/2.6%20npm%20%E4%BD%BF%E7%94%A8%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A1%B9.md#262-npm-install)\n        - [npm scripts](https://github.com/nswbmw/N-blog/blob/master/book/2.6%20npm%20%E4%BD%BF%E7%94%A8%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A1%B9.md#263-npm-scripts)\n        - [npm shrinkwrap ](https://github.com/nswbmw/N-blog/blob/master/book/2.6%20npm%20%E4%BD%BF%E7%94%A8%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A1%B9.md#264-npm-shrinkwrap)\n- Hello, Express\n    - [初始化一个 Express 项目](https://github.com/nswbmw/N-blog/blob/master/book/3.1%20%E5%88%9D%E5%A7%8B%E5%8C%96%E4%B8%80%E4%B8%AA%20Express%20%E9%A1%B9%E7%9B%AE.md)\n        - [supervisor](https://github.com/nswbmw/N-blog/blob/master/book/3.1%20%E5%88%9D%E5%A7%8B%E5%8C%96%E4%B8%80%E4%B8%AA%20Express%20%E9%A1%B9%E7%9B%AE.md#311-supervisor)\n    - [路由](https://github.com/nswbmw/N-blog/blob/master/book/3.2%20%E8%B7%AF%E7%94%B1.md)\n        - [express.Router](https://github.com/nswbmw/N-blog/blob/master/book/3.2%20%E8%B7%AF%E7%94%B1.md#321-expressrouter)\n    - [模板引擎](https://github.com/nswbmw/N-blog/blob/master/book/3.3%20%E6%A8%A1%E6%9D%BF%E5%BC%95%E6%93%8E.md)\n        - [ejs](https://github.com/nswbmw/N-blog/blob/master/book/3.3%20%E6%A8%A1%E6%9D%BF%E5%BC%95%E6%93%8E.md#331-ejs)\n        - [includes](https://github.com/nswbmw/N-blog/blob/master/book/3.3%20%E6%A8%A1%E6%9D%BF%E5%BC%95%E6%93%8E.md#332-includes)\n    - [Express 浅析](https://github.com/nswbmw/N-blog/blob/master/book/3.4%20Express%20%E6%B5%85%E6%9E%90.md)\n        - [中间件与 next](https://github.com/nswbmw/N-blog/blob/master/book/3.4%20Express%20%E6%B5%85%E6%9E%90.md#341-中间件与-next)\n        - [错误处理](https://github.com/nswbmw/N-blog/blob/master/book/3.4%20Express%20%E6%B5%85%E6%9E%90.md#342-错误处理)\n- 一个简单的博客\n    - [开发环境](https://github.com/nswbmw/N-blog/blob/master/book/4.1%20%E5%BC%80%E5%8F%91%E7%8E%AF%E5%A2%83.md)\n    - [准备工作](https://github.com/nswbmw/N-blog/blob/master/book/4.2%20%E5%87%86%E5%A4%87%E5%B7%A5%E4%BD%9C.md)\n        - [目录结构](https://github.com/nswbmw/N-blog/blob/master/book/4.2%20%E5%87%86%E5%A4%87%E5%B7%A5%E4%BD%9C.md#421-目录结构)\n        - [安装依赖模块](https://github.com/nswbmw/N-blog/blob/master/book/4.2%20%E5%87%86%E5%A4%87%E5%B7%A5%E4%BD%9C.md#422-安装依赖模块)\n        - [ESLint](https://github.com/nswbmw/N-blog/blob/master/book/4.2%20%E5%87%86%E5%A4%87%E5%B7%A5%E4%BD%9C.md#423-eslint)\n        - [EditorConfig](https://github.com/nswbmw/N-blog/blob/master/book/4.2%20%E5%87%86%E5%A4%87%E5%B7%A5%E4%BD%9C.md#424-editorconfig)\n    - [配置文件](https://github.com/nswbmw/N-blog/blob/master/book/4.3%20%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6.md)\n        - [config-lite](https://github.com/nswbmw/N-blog/blob/master/book/4.3%20%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6.md#431-config-lite)\n    - [功能设计](https://github.com/nswbmw/N-blog/blob/master/book/4.4%20%E5%8A%9F%E8%83%BD%E8%AE%BE%E8%AE%A1.md)\n        - [功能与路由设计](https://github.com/nswbmw/N-blog/blob/master/book/4.4%20%E5%8A%9F%E8%83%BD%E8%AE%BE%E8%AE%A1.md#441-功能与路由设计)\n        - [会话](https://github.com/nswbmw/N-blog/blob/master/book/4.4%20%E5%8A%9F%E8%83%BD%E8%AE%BE%E8%AE%A1.md#442-会话)\n        - [页面通知](https://github.com/nswbmw/N-blog/blob/master/book/4.4%20%E5%8A%9F%E8%83%BD%E8%AE%BE%E8%AE%A1.md#443-页面通知)\n        - [权限控制](https://github.com/nswbmw/N-blog/blob/master/book/4.4%20%E5%8A%9F%E8%83%BD%E8%AE%BE%E8%AE%A1.md#444-权限控制)\n    - [页面设计](https://github.com/nswbmw/N-blog/blob/master/book/4.5%20%E9%A1%B5%E9%9D%A2%E8%AE%BE%E8%AE%A1.md)\n        - [组件](https://github.com/nswbmw/N-blog/blob/master/book/4.5%20%E9%A1%B5%E9%9D%A2%E8%AE%BE%E8%AE%A1.md#451-组件)\n        - [app.locals 和 res.locals](https://github.com/nswbmw/N-blog/blob/master/book/4.5%20%E9%A1%B5%E9%9D%A2%E8%AE%BE%E8%AE%A1.md#452-applocals-和-reslocals)\n    - [连接数据库](https://github.com/nswbmw/N-blog/blob/master/book/4.6%20%E8%BF%9E%E6%8E%A5%E6%95%B0%E6%8D%AE%E5%BA%93.md)\n        - [为什么使用 Mongolass](https://github.com/nswbmw/N-blog/blob/master/book/4.6%20%E8%BF%9E%E6%8E%A5%E6%95%B0%E6%8D%AE%E5%BA%93.md#461-为什么使用-mongolass)\n    - [注册](https://github.com/nswbmw/N-blog/blob/master/book/4.7%20%E6%B3%A8%E5%86%8C.md)\n        - [用户模型设计](https://github.com/nswbmw/N-blog/blob/master/book/4.7%20%E6%B3%A8%E5%86%8C.md#471-用户模型设计)\n        - [注册页](https://github.com/nswbmw/N-blog/blob/master/book/4.7%20%E6%B3%A8%E5%86%8C.md#472-注册页)\n        - [注册与文件上传](https://github.com/nswbmw/N-blog/blob/master/book/4.7%20%E6%B3%A8%E5%86%8C.md#473-注册与文件上传)\n    - [登出与登录](https://github.com/nswbmw/N-blog/blob/master/book/4.8%20%E7%99%BB%E5%87%BA%E4%B8%8E%E7%99%BB%E5%BD%95.md)\n        - [登出](https://github.com/nswbmw/N-blog/blob/master/book/4.8%20%E7%99%BB%E5%87%BA%E4%B8%8E%E7%99%BB%E5%BD%95.md#481-登出)\n        - [登录页](https://github.com/nswbmw/N-blog/blob/master/book/4.8%20%E7%99%BB%E5%87%BA%E4%B8%8E%E7%99%BB%E5%BD%95.md#482-登录页)\n        - [登录](https://github.com/nswbmw/N-blog/blob/master/book/4.8%20%E7%99%BB%E5%87%BA%E4%B8%8E%E7%99%BB%E5%BD%95.md#483-登录)\n    - [文章](https://github.com/nswbmw/N-blog/blob/master/book/4.9%20%E6%96%87%E7%AB%A0.md)\n        - [文章模型设计](https://github.com/nswbmw/N-blog/blob/master/book/4.9%20%E6%96%87%E7%AB%A0.md#491-文章模型设计)\n        - [发表文章](https://github.com/nswbmw/N-blog/blob/master/book/4.9%20%E6%96%87%E7%AB%A0.md#492-发表文章)\n        - [主页与文章页](https://github.com/nswbmw/N-blog/blob/master/book/4.9%20%E6%96%87%E7%AB%A0.md#493-主页与文章页)\n        - [编辑与删除文章](https://github.com/nswbmw/N-blog/blob/master/book/4.9%20%E6%96%87%E7%AB%A0.md#494-编辑与删除文章)\n    - [留言](https://github.com/nswbmw/N-blog/blob/master/book/4.10%20%E7%95%99%E8%A8%80.md)\n        - [留言模型设计](https://github.com/nswbmw/N-blog/blob/master/book/4.10%20%E7%95%99%E8%A8%80.md#4101-留言模型设计)\n        - [显示留言](https://github.com/nswbmw/N-blog/blob/master/book/4.10%20%E7%95%99%E8%A8%80.md#4102-显示留言)\n        - [发表与删除留言](https://github.com/nswbmw/N-blog/blob/master/book/4.10%20%E7%95%99%E8%A8%80.md#4103-发表与删除留言)\n    - [404页面](https://github.com/nswbmw/N-blog/blob/master/book/4.11%20404%20%E9%A1%B5%E9%9D%A2.md)\n    - [错误页面](https://github.com/nswbmw/N-blog/blob/master/book/4.12%20%E9%94%99%E8%AF%AF%E9%A1%B5%E9%9D%A2.md)\n    - [日志](https://github.com/nswbmw/N-blog/blob/master/book/4.13%20%E6%97%A5%E5%BF%97.md)\n        - [winston 和 express-winston](https://github.com/nswbmw/N-blog/blob/master/book/4.13%20%E6%97%A5%E5%BF%97.md#4131-winston-和-express-winston)\n        - [.gitignore](https://github.com/nswbmw/N-blog/blob/master/book/4.13%20%E6%97%A5%E5%BF%97.md#4132-gitignore)\n    - [测试](https://github.com/nswbmw/N-blog/blob/master/book/4.14%20%E6%B5%8B%E8%AF%95.md)\n        - [mocha 和 supertest](https://github.com/nswbmw/N-blog/blob/master/book/4.14%20%E6%B5%8B%E8%AF%95.md#4141-mocha-和-supertest)\n        - [测试覆盖率](https://github.com/nswbmw/N-blog/blob/master/book/4.14%20%E6%B5%8B%E8%AF%95.md#4142-测试覆盖率)\n    - [部署](https://github.com/nswbmw/N-blog/blob/master/book/4.15%20%E9%83%A8%E7%BD%B2.md)\n        - [申请 MLab](https://github.com/nswbmw/N-blog/blob/master/book/4.15%20%E9%83%A8%E7%BD%B2.md#4151-申请-mlab)\n        - [pm2](https://github.com/nswbmw/N-blog/blob/master/book/4.15%20%E9%83%A8%E7%BD%B2.md#4152-pm2)\n        - [部署到 Heroku](https://github.com/nswbmw/N-blog/blob/master/book/4.15%20%E9%83%A8%E7%BD%B2.md#4152-部署到-heroku)\n        - [部署到 UCloud](https://github.com/nswbmw/N-blog/blob/master/book/4.15%20%E9%83%A8%E7%BD%B2.md#4153-部署到-ucloud)\n        - [部署到阿里云](https://github.com/nswbmw/N-blog/blob/master/book/4.15%20%E9%83%A8%E7%BD%B2.md#4154-部署到阿里云)\n    - 扩展训练\n        - 添加分页功能\n        - 添加二级评论功能\n        - 添加标签(tag)功能\n\n## 捐赠\n\n您的捐赠，是我持续开源的动力。\n\n支付宝 | 微信\n------|------\n![](./public/alipay.png) | ![](./public/wechat.jpeg)\n"
  },
  {
    "path": "book/1.1 Node.js 的安装与使用.md",
    "content": "## 1.1.1 安装 Node.js\n\n有三种方式安装 Node.js：一是通过安装包安装，二是通过源码编译安装，三是在 Linux 下可以通过 yum|apt-get 安装，在 Mac 下可以通过 [Homebrew](http://brew.sh/) 安装。对于 Windows 和 Mac 用户，推荐使用安装包安装，Linux 用户推荐使用源码编译安装。\n\n#### Windows 和 Mac 安装：\n\n**第一步：**\n\n打开 [Node.js 官网](https://nodejs.org/en/)，可以看到以下两个下载选项：\n\n![](./img/1.1.1.png)\n\n左边的是 LTS 版，用过 ubuntu 的同学可能比较熟悉，即长期支持版本，大多数人用这个就可以了。右边是最新版，支持最新的语言特性（比如对 ES6 的支持更全面），想尝试新特性的开发者可以安装这个版本。我们选择左边的 v6.9.1 LTS 点击下载。\n\n> 小提示：从 [http://node.green](http://node.green) 上可以看到 Node.js 各个版本对 ES6 的支持情况。\n\n**第二步：**\n\n安装 Node.js，这个没什么好说的，一直点击 `继续` 即可。\n\n![](./img/1.1.2.png)\n\n**第三步：**\n\n提示安装成功后，打开终端输入以下命令，可以看到 node 和 npm 都已经安装好了：\n\n![](./img/1.1.3.png)\n\n#### Linux 安装：\n\nLinux 用户可通过源码编译安装：\n\n```sh\ncurl -O https://nodejs.org/dist/v6.9.1/node-v6.9.1.tar.gz\ntar -xzvf node-v6.9.1.tar.gz\ncd node-v6.9.1\n./configure\nmake\nmake install\n```\n\n> 注意: 如果编译过程报错，可能是缺少某些依赖包。因为报错内容不尽相同，请读者自行求助搜索引擎或 [stackoverflow](http://stackoverflow.com/)。\n\n## 1.1.2 n 和 nvm\n\n通常我们使用稳定的 LTS 版本的 Node.js 即可，但有的情况下我们又想尝试一下新的特性，我们总不能来回安装不同版本的 Node.js 吧，这个时候我们就需要 [n](https://github.com/tj/n) 或者 [nvm](https://github.com/creationix/nvm) 了。n 和 nvm 是两个常用的 Node.js 版本管理工具，关于 n 和 nvm 的使用以及区别，[这篇文章](http://taobaofed.org/blog/2015/11/17/nvm-or-n/) 讲得特别详细，这里不再赘述。\n\n## 1.1.3 nrm\n\n[nrm](https://github.com/Pana/nrm) 是一个管理 npm 源的工具。用过 ruby 和 gem 的同学会比较熟悉，通常我们会把 gem 源切到国内的淘宝镜像，这样在安装和更新一些包的时候比较快。nrm 同理，用来切换官方 npm 源和国内的 npm 源（如: [cnpm](http://cnpmjs.org/)），当然也可以用来切换官方 npm 源和公司私有 npm 源。\n\n全局安装 nrm:\n\n```sh\nnpm i nrm -g\n```\n\n查看当前 nrm 内置的几个 npm 源的地址：\n\n![](./img/1.1.4.png)\n\n切换到 cnpm：\n\n![](./img/1.1.5.png)\n\n下一节：[1.2 MongoDB 的安装与使用](https://github.com/nswbmw/N-blog/blob/master/book/1.2%20MongoDB%20%E7%9A%84%E5%AE%89%E8%A3%85%E4%B8%8E%E4%BD%BF%E7%94%A8.md)"
  },
  {
    "path": "book/1.2 MongoDB 的安装与使用.md",
    "content": "## 1.2.1 安装与启动 MongoDB\n\n- Windows 用户向导：https://docs.mongodb.com/manual/tutorial/install-mongodb-on-windows/\n- Linux 用户向导：https://docs.mongodb.com/manual/administration/install-on-linux/\n- Mac 用户向导：https://docs.mongodb.com/manual/tutorial/install-mongodb-on-os-x/\n\n## 1.2.2 Robomongo 和 Mongochef\n\n#### Robomongo\n\n[Robomongo](https://robomongo.org/) 是一个基于 Shell 的跨平台开源 MongoDB 可视化管理工具，支持 Windows、Linux 和 Mac，嵌入了 JavaScript 引擎和 MongoDB mongo，只要你会使用 mongo shell，你就会使用 Robomongo，它还提供了语法高亮、自动补全、差别视图等。\n\n[Robomongo 下载地址](https://robomongo.org/download)\n\n下载并安装成功后点击左上角的 `Create` 创建一个连接，给该连接起个名字如: `localhost`，使用默认地址（localhost）和端口（27017）即可，点击 `Save` 保存。\n\n![](./img/1.2.1.png)\n\n\n双击 `localhost` 连接到 MongoDB 并进入交互界面，尝试插入一条数据并查询出来，如下所示:\n\n![](./img/1.2.2.png)\n\n\n#### MongoChef\n\n[MongoChef](http://3t.io/mongochef/) 是另一款强大的 MongoDB 可视化管理工具，支持 Windows、Linux 和 Mac。\n\n[MongoChef 下载地址](http://3t.io/mongochef/#mongochef-download-compare)，我们选择左侧的非商业用途的免费版下载。\n\n![](./img/1.2.3.png)\n\n安装成功后跟 Robomongo 一样，也需要创建一个新的连接的配置，成功后双击进入到 MongoChef 主页面，如下所示:\n\n![](./img/1.2.4.png)\n\n还可以使用 shell 模式:\n\n![](./img/1.2.5.png)\n\n> 小提示: MongoChef 相较于 Robomongo 更强大一些，但 Robomongo 比较轻量也能满足大部分的常规需求，所以哪一个适合自己还需读者自行尝试。\n\n上一节：[1.1 Node.js 的安装与使用](https://github.com/nswbmw/N-blog/blob/master/book/1.1%20Node.js%20%E7%9A%84%E5%AE%89%E8%A3%85%E4%B8%8E%E4%BD%BF%E7%94%A8.md)\n\n下一节：[2.1 require](https://github.com/nswbmw/N-blog/blob/master/book/2.1%20require.md)\n"
  },
  {
    "path": "book/2.1 require.md",
    "content": "require 用来加载一个文件的代码，关于 require 的机制这里不展开讲解，请仔细阅读 [官方文档](https://nodejs.org/api/modules.html)。\n\n简单概括以下几点:\n\n- require 可加载 .js、.json 和 .node 后缀的文件\n- require 的过程是同步的，所以这样是错误的:\n```sh\nsetTimeout(() => {\n  module.exports = { a: 'hello' }\n}, 0)\n```\nrequire 这个文件得到的是空对象 `{}`\n\n- require 目录的机制是:\n  - 如果目录下有 package.json 并指定了 main 字段，则用之\n  - 如果不存在 package.json，则依次尝试加载目录下的 index.js 和 index.node\n- require 过的文件会加载到缓存，所以多次 require 同一个文件（模块）不会重复加载\n- 判断是否是程序的入口文件有两种方式:\n  - require.main === module（推荐）\n  - module.parent === null\n\n\n#### 循环引用\n\n循环引用（或循环依赖）简单点来说就是 a 文件 require 了 b 文件，然后 b 文件又反过来 require 了 a 文件。我们用 a->b 代表 b require 了 a。\n\n简单的情况:\n\n```\na->b\nb->a\n```\n\n复杂点的情况:\n\n```\na->b\nb->c\nc->a\n```\n\n循环引用并不会报错，导致的结果是 require 的结果是空对象 `{}`，原因是 b require 了 a，a 又去 require 了 b，此时 b 还没初始化好，所以只能拿到初始值 `{}`。当产生循环引用时一般有两种方法解决：\n\n1. 通过分离共用的代码到另一个文件解决，如上面简单的情况，可拆出共用的代码到 c 中，如下:\n\n  ```\n  c->a\n  c->b\n  ```\n\n2. 不在最外层 require，在用到的地方 require，通常在函数的内部\n\n总的来说，循环依赖的陷阱并不大容易出现，但一旦出现了，对于新手来说还真不好定位。它的存在给我们提了个醒，要时刻注意你项目的依赖关系不要过于复杂，哪天你发现一个你明明已经 exports 了的方法报 `undefined is not a function`，我们就该提醒一下自己：哦，也许是它来了。\n\n官方示例: [https://nodejs.org/api/modules.html#modules_cycles](https://nodejs.org/api/modules.html#modules_cycles)\n\n上一节：[1.2 MongoDB 的安装与使用](https://github.com/nswbmw/N-blog/blob/master/book/1.2%20MongoDB%20%E7%9A%84%E5%AE%89%E8%A3%85%E4%B8%8E%E4%BD%BF%E7%94%A8.md)\n\n下一节：[2.2 exports 和 module.exports](https://github.com/nswbmw/N-blog/blob/master/book/2.2%20exports%20%E5%92%8C%20module.exports.md)\n"
  },
  {
    "path": "book/2.2 exports 和 module.exports.md",
    "content": "require 用来加载代码，而 exports 和 module.exports 则用来导出代码。\n\n很多新手可能会迷惑于 exports 和 module.exports 的区别，为了更好的理解 exports 和 module.exports 的关系，我们先来巩固下 js 的基础。示例：\n\n**test.js**\n\n```js\nvar a = {name: 1}\nvar b = a\n\nconsole.log(a)\nconsole.log(b)\n\nb.name = 2\nconsole.log(a)\nconsole.log(b)\n\nvar b = {name: 3}\nconsole.log(a)\nconsole.log(b)\n```\n\n运行 test.js 结果为：\n\n```\n{ name: 1 }\n{ name: 1 }\n{ name: 2 }\n{ name: 2 }\n{ name: 2 }\n{ name: 3 }\n```\n\n**解释**：a 是一个对象，b 是对 a 的引用，即 a 和 b 指向同一块内存，所以前两个输出一样。当对 b 作修改时，即 a 和 b 指向同一块内存地址的内容发生了改变，所以 a 也会体现出来，所以第三四个输出一样。当 b 被覆盖时，b 指向了一块新的内存，a 还是指向原来的内存，所以最后两个输出不一样。\n\n明白了上述例子后，我们只需知道三点就知道 exports 和 module.exports 的区别了：\n\n1. module.exports 初始值为一个空对象 {}\n2. exports 是指向的 module.exports 的引用\n3. require() 返回的是 module.exports 而不是 exports\n\nNode.js 官方文档的截图证实了我们的观点:\n\n![](./img/2.2.1.png)\n\n#### exports = module.exports = {...}\n\n我们经常看到这样的写法：\n\n```js\nexports = module.exports = {...}\n```\n\n上面的代码等价于:\n\n```js\nmodule.exports = {...}\nexports = module.exports\n```\n\n原理很简单：module.exports 指向新的对象时，exports 断开了与 module.exports 的引用，那么通过 exports = module.exports 让 exports 重新指向 module.exports。\n\n> 小提示：ES6 的 import 和 export 不在本文的讲解范围，有兴趣的读者可以去学习阮一峰老师的[《ECMAScript6入门》](http://es6.ruanyifeng.com/)。\n\n上一节：[2.1 require](https://github.com/nswbmw/N-blog/blob/master/book/2.1%20require.md)\n\n下一节：[2.3 Promise](https://github.com/nswbmw/N-blog/blob/master/book/2.3%20Promise.md)"
  },
  {
    "path": "book/2.3 Promise.md",
    "content": "网上已经有许多关于 Promise 的资料了，这里不在赘述。以下 4 个链接供读者学习：\n\n1. https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise （基础）\n2. http://liubin.org/promises-book/ （开源 Promise 迷你书）\n3. http://fex.baidu.com/blog/2015/07/we-have-a-problem-with-promises/ （进阶）\n4. https://promisesaplus.com/ （官方定义规范）\n\nPromise 用于异步流程控制，生成器与 yield 也能实现流程控制（基于 co），但不在本教程讲解范围内，读者可参考我的另一部教程 [N-club](https://github.com/nswbmw/N-club)。async/await 结合 Promise 也可以实现流程控制，有兴趣请查阅 [《ECMAScript6入门》](http://es6.ruanyifeng.com/#docs/async#async函数)。\n\n### 深入 Promise\n\n- [Promise 必知必会（十道题）](https://zhuanlan.zhihu.com/p/30797777)\n- [深入 Promise(一)——Promise 实现详解](https://zhuanlan.zhihu.com/p/25178630)\n- [深入 Promise(二)——进击的 Promise](https://zhuanlan.zhihu.com/p/25198178)\n- [深入 Promise(三)——命名 Promise](https://zhuanlan.zhihu.com/p/25199781)\n\n上一节：[2.2 exports 和 module.exports](https://github.com/nswbmw/N-blog/blob/master/book/2.2%20exports%20%E5%92%8C%20module.exports.md)\n\n下一节：[2.4 环境变量](https://github.com/nswbmw/N-blog/blob/master/book/2.4%20%E7%8E%AF%E5%A2%83%E5%8F%98%E9%87%8F.md)\n"
  },
  {
    "path": "book/2.4 环境变量.md",
    "content": "环境变量不属于 Node.js 的知识范畴，只不过我们在开发 Node.js 应用时经常与环境变量打交道，所以这里简单介绍下。\n\n环境变量（environment variables）一般是指在操作系统中用来指定操作系统运行环境的一些参数。在 Mac 和 Linux 的终端直接输入 env，会列出当前的环境变量，如：USER=xxx。简单来讲，环境变量就是传递参数给运行程序的。\n\n在 Node.js 中，我们经常这么用:\n\n```sh\nNODE_ENV=test node app\n```\n\n通过以上命令启动程序，指定当前环境变量 `NODE_ENV` 的值为 test，那么在 app.js 中可通过 `process.env` 来获取环境变量:\n\n```\nconsole.log(process.env.NODE_ENV) //test\n```\n\n另一个常见的例子是使用 [debug](https://www.npmjs.com/package/debug) 模块时:\n\n```sh\nDEBUG=* node app\n```\n\nWindows 用户需要首先设置环境变量，然后再执行程序：\n\n```sh\nset DEBUG=*\nset NODE_ENV=test\nnode app\n```\n\n或者使用 [cross-env](https://www.npmjs.com/package/cross-env)：\n\n```sh\nnpm i cross-env -g\n```\n\n使用方式：\n\n```sh\ncross-env NODE_ENV=test node app\n```\n\n上一节：[2.3 Promise](https://github.com/nswbmw/N-blog/blob/master/book/2.3%20Promise.md)\n\n下一节：[2.5 packge.json](https://github.com/nswbmw/N-blog/blob/master/book/2.5%20package.json.md)"
  },
  {
    "path": "book/2.5 package.json.md",
    "content": "package.json 对于 Node.js 应用来说是一个不可或缺的文件，它存储了该 Node.js 应用的名字、版本、描述、作者、入口文件、脚本、版权等等信息。npm 官网有 package.json 每个字段的详细介绍：[https://docs.npmjs.com/files/package.json](https://docs.npmjs.com/files/package.json)。\n\n## 2.5.1 semver\n\n语义化版本（semver）即 dependencies、devDependencies 和 peerDependencies 里的如：`\"co\": \"^4.6.0\"`。\n\nsemver 格式：`主版本号.次版本号.修订号`。版本号递增规则如下：\n\n- `主版本号`：做了不兼容的 API 修改\n- `次版本号`：做了向下兼容的功能性新增\n- `修订号`：做了向下兼容的 bug 修正\n\n更多阅读：\n\n1. http://semver.org/lang/zh-CN/\n2. http://taobaofed.org/blog/2016/08/04/instructions-of-semver/\n\n作为 Node.js 的开发者，我们在发布 npm 模块的时候一定要遵守语义化版本的命名规则，即：有 breaking change 发大版本，有新增的功能发小版本，有小的 bug 修复或优化则发修订版本。\n\n上一节：[2.4 环境变量](https://github.com/nswbmw/N-blog/blob/master/book/2.4%20%E7%8E%AF%E5%A2%83%E5%8F%98%E9%87%8F.md)\n\n下一节：[2.6 npm 使用注意事项](https://github.com/nswbmw/N-blog/blob/master/book/2.6%20npm%20%E4%BD%BF%E7%94%A8%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A1%B9.md)"
  },
  {
    "path": "book/2.6 npm 使用注意事项.md",
    "content": "## 2.6.1 npm init\n\n使用 `npm init` 初始化一个空项目是一个好的习惯，即使你对 package.json 及其他属性非常熟悉，`npm init` 也是你开始写新的 Node.js 应用或模块的一个快捷的办法。`npm init` 有智能的默认选项，比如从根目录名称推断模块名称，通过 `~/.npmrc` 读取你的信息，用你的 Git 设置来确定 repository 等等。\n\n## 2.6.2 npm install\n\n`npm install` 是我们最常用的 npm 命令之一，因此我们需要好好了解下这个命令。终端输入 `npm install -h` 查看使用方式:\n\n![](./img/2.6.1.png)\n\n可以看出：我们通过 `npm install` 可以安装 npm 上发布的某个版本、某个tag、某个版本区间的模块，甚至可以安装本地目录、压缩包和 git/github 的库作为依赖。\n\n> 小提示: `npm i` 是 `npm install` 的简写，建议使用 `npm i`。\n\n直接使用 `npm i` 安装的模块是不会写入 package.json 的 dependencies (或 devDependencies)，需要额外加个参数:\n\n1. `npm i express --save`/`npm i express -S` (安装 express，同时将 `\"express\": \"^4.14.0\"` 写入 dependencies )\n2. `npm i express --save-dev`/`npm i express -D` (安装 express，同时将 `\"express\": \"^4.14.0\"` 写入 devDependencies )\n3. `npm i express --save --save-exact` (安装 express，同时将 `\"express\": \"4.14.0\"` 写入 dependencies )\n\n第三种方式将固定版本号写入 dependencies，建议线上的 Node.js 应用都采取这种锁定版本号的方式，因为你不可能保证第三方模块下个小版本是没有验证 bug 的，即使是很流行的模块。拿 Mongoose 来说，Mongoose 4.1.4 引入了一个 bug 导致调用一个文档 entry 的 remove 会删除整个集合的文档，见：[https://github.com/Automattic/mongoose/blob/master/History.md#415--2015-09-01](https://github.com/Automattic/mongoose/blob/master/History.md#415--2015-09-01)。\n\n> 后面会介绍更安全的 `npm shrinkwrap` 的用法。\n\n运行以下命令：\n\n```sh\nnpm config set save-exact true\n```\n\n这样每次 `npm i xxx --save` 的时候会锁定依赖的版本号，相当于加了 `--save-exact` 参数。\n\n> 小提示：`npm config set` 命令将配置写到了 ~/.npmrc 文件，运行 `npm config list` 查看。\n\n## 2.6.3 npm scripts\n\nnpm 提供了灵活而强大的 scripts 功能，见 [官方文档](https://docs.npmjs.com/misc/scripts)。\n\nnpm 的 scripts 有一些内置的缩写命令，如常用的：\n\n- `npm start` 等价于 `npm run start` \n- `npm test` 等价于 `npm run test` \n\n## 2.6.4 npm shrinkwrap\n\n前面说过要锁定依赖的版本，但这并不能完全防止意外情况的发生，因为锁定的只是最外一层的依赖，而里层依赖的模块的 package.json 有可能写的是 `\"mongoose\": \"*\"`。为了彻底锁定依赖的版本，让你的应用在任何机器上安装的都是同样版本的模块（不管嵌套多少层），通过运行 `npm shrinkwrap`，会在当前目录下产生一个 `npm-shrinkwrap.json`，里面包含了通过 node_modules 计算出的模块的依赖树及版本。上面的截图也显示：只要目录下有 npm-shrinkwrap.json 则运行 `npm install` 的时候会优先使用 npm-shrinkwrap.json 进行安装，没有则使用 package.json 进行安装。\n\n更多阅读：\n\n1. https://docs.npmjs.com/cli/shrinkwrap\n2. http://tech.meituan.com/npm-shrinkwrap.html\n\n> 注意: 如果 node_modules 下存在某个模块（如直接通过 `npm install xxx` 安装的）而 package.json 中没有，运行 `npm shrinkwrap` 则会报错。另外，`npm shrinkwrap` 只会生成 dependencies 的依赖，不会生成 devDependencies 的。\n\n上一节：[2.5 packge.json](https://github.com/nswbmw/N-blog/blob/master/book/2.5%20package.json.md)\n\n下一节：[3.1 初始化一个 Express 项目](https://github.com/nswbmw/N-blog/blob/master/book/3.1%20%E5%88%9D%E5%A7%8B%E5%8C%96%E4%B8%80%E4%B8%AA%20Express%20%E9%A1%B9%E7%9B%AE.md)"
  },
  {
    "path": "book/3.1 初始化一个 Express 项目.md",
    "content": "首先，我们新建一个目录 myblog，在该目录下运行 `npm init` 生成一个 package.json，如下所示：\n\n![](./img/3.1.1.png)\n\n> 注意：括号里的是默认值，如果使用默认值则直接回车即可，否则输入自定义内容后回车。\n\n然后安装 express 并写入 package.json：\n\n```sh\nnpm i express@4.14.0 --save \n```\n\n新建 index.js，添加如下代码：\n\n```js\nconst express = require('express')\nconst app = express()\n\napp.get('/', function (req, res) {\n  res.send('hello, express')\n})\n\napp.listen(3000)\n```\n\n以上代码的意思是：生成一个 express 实例 app，挂载了一个根路由控制器，然后监听 3000 端口并启动程序。运行 `node index`，打开浏览器访问 `localhost:3000` 时，页面应显示 hello, express。\n\n这是最简单的一个使用 express 的例子，后面会介绍路由及模板的使用。\n\n## 3.1.1 supervisor\n\n在开发过程中，每次修改代码保存后，我们都需要手动重启程序，才能查看改动的效果。使用 [supervisor](https://www.npmjs.com/package/supervisor) 可以解决这个繁琐的问题，全局安装 supervisor：\n\n```sh\nnpm i -g supervisor\n```\n\n运行 `supervisor index` 启动程序，如下所示：\n\n![](./img/3.1.2.png)\n\nsupervisor 会监听当前目录下 node 和 js 后缀的文件，当这些文件发生改动时，supervisor 会自动重启程序。\n\n上一节：[2.6 npm 使用注意事项](https://github.com/nswbmw/N-blog/blob/master/book/2.6%20npm%20%E4%BD%BF%E7%94%A8%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A1%B9.md)\n\n下一节：[3.2 路由](https://github.com/nswbmw/N-blog/blob/master/book/3.2%20%E8%B7%AF%E7%94%B1.md)"
  },
  {
    "path": "book/3.2 路由.md",
    "content": "前面我们只是挂载了根路径的路由控制器，现在修改 index.js 如下：\n\n```js\nconst express = require('express')\nconst app = express()\n\napp.get('/', function (req, res) {\n  res.send('hello, express')\n})\n\napp.get('/users/:name', function (req, res) {\n  res.send('hello, ' + req.params.name)\n})\n\napp.listen(3000)\n```\n\n以上代码的意思是：当访问根路径时，依然返回 hello, express，当访问如 `localhost:3000/users/nswbmw` 路径时，返回 hello, nswbmw。路径中 `:name` 起了占位符的作用，这个占位符的名字是 name，可以通过 `req.params.name` 取到实际的值。\n\n> 小提示：express 使用了 [path-to-regexp](https://www.npmjs.com/package/path-to-regexp) 模块实现的路由匹配。\n\n不难看出：req 包含了请求来的相关信息，res 则用来返回该请求的响应，更多请查阅 [express 官方文档](http://expressjs.com/en/4x/api.html)。下面介绍几个常用的 req 的属性：\n\n- `req.query`: 解析后的 url 中的 querystring，如 `?name=haha`，req.query 的值为 `{name: 'haha'}`\n- `req.params`: 解析 url 中的占位符，如 `/:name`，访问 /haha，req.params 的值为 `{name: 'haha'}`\n- `req.body`: 解析后请求体，需使用相关的模块，如 [body-parser](https://www.npmjs.com/package/body-parser)，请求体为 `{\"name\": \"haha\"}`，则 req.body 为 `{name: 'haha'}`\n\n## 3.2.1 express.Router\n\n上面只是很简单的路由使用的例子（将所有路由控制函数都放到了 index.js），但在实际开发中通常有几十甚至上百的路由，都写在 index.js 既臃肿又不好维护，这时可以使用 express.Router 实现更优雅的路由解决方案。在 myblog 目录下创建空文件夹 routes，在 routes 目录下创建 index.js 和 users.js。最后代码如下：\n\n**index.js**\n\n```js\nconst express = require('express')\nconst app = express()\nconst indexRouter = require('./routes/index')\nconst userRouter = require('./routes/users')\n\napp.use('/', indexRouter)\napp.use('/users', userRouter)\n\napp.listen(3000)\n```\n\n**routes/index.js**\n\n```js\nconst express = require('express')\nconst router = express.Router()\n\nrouter.get('/', function (req, res) {\n  res.send('hello, express')\n})\n\nmodule.exports = router\n```\n\n**routes/users.js**\n\n```js\nconst express = require('express')\nconst router = express.Router()\n\nrouter.get('/:name', function (req, res) {\n  res.send('hello, ' + req.params.name)\n})\n\nmodule.exports = router\n```\n\n以上代码的意思是：我们将 `/` 和 `/users/:name` 的路由分别放到了 routes/index.js 和 routes/users.js 中，每个路由文件通过生成一个 express.Router 实例 router 并导出，通过 `app.use` 挂载到不同的路径。这两种代码实现了相同的功能，但在实际开发中推荐使用 express.Router 将不同的路由分离到不同的路由文件中。\n\n更多 express.Router 的用法见 [express 官方文档](http://expressjs.com/en/4x/api.html#router)。\n\n上一节：[3.1 初始化一个 Express 项目](https://github.com/nswbmw/N-blog/blob/master/book/3.1%20%E5%88%9D%E5%A7%8B%E5%8C%96%E4%B8%80%E4%B8%AA%20Express%20%E9%A1%B9%E7%9B%AE.md)\n\n下一节：[3.3 模板引擎](https://github.com/nswbmw/N-blog/blob/master/book/3.3%20%E6%A8%A1%E6%9D%BF%E5%BC%95%E6%93%8E.md)"
  },
  {
    "path": "book/3.3 模板引擎.md",
    "content": "模板引擎（Template Engine）是一个将页面模板和数据结合起来生成 html 的工具。上例中，我们只是返回纯文本给浏览器，现在我们修改代码返回一个 html 页面给浏览器。\n\n## 3.3.1 ejs\n\n模板引擎有很多，[ejs](https://www.npmjs.com/package/ejs) 是其中一种，因为它使用起来十分简单，而且与 express 集成良好，所以我们使用 ejs。安装 ejs：\n\n```sh\nnpm i ejs --save\n```\n\n修改 index.js 如下：\n\n**index.js**\n\n```js\nconst path = require('path')\nconst express = require('express')\nconst app = express()\nconst indexRouter = require('./routes/index')\nconst userRouter = require('./routes/users')\n\napp.set('views', path.join(__dirname, 'views'))// 设置存放模板文件的目录\napp.set('view engine', 'ejs')// 设置模板引擎为 ejs\n\napp.use('/', indexRouter)\napp.use('/users', userRouter)\n\napp.listen(3000)\n```\n\n通过 `app.set` 设置模板引擎为 ejs 和存放模板的目录。在 myblog 下新建 views 文件夹，在 views 下新建 users.ejs，添加如下代码：\n\n**views/users.ejs**\n\n```html\n<!DOCTYPE html>\n<html>\n  <head>\n    <style type=\"text/css\">\n      body {padding: 50px;font: 14px \"Lucida Grande\", Helvetica, Arial, sans-serif;}\n    </style>\n  </head>\n  <body>\n    <h1><%= name.toUpperCase() %></h1>\n    <p>hello, <%= name %></p>\n  </body>\n</html>\n```\n\n修改 routes/users.js 如下：\n\n**routes/users.js**\n\n```js\nconst express = require('express')\nconst router = express.Router()\n\nrouter.get('/:name', function (req, res) {\n  res.render('users', {\n    name: req.params.name\n  })\n})\n\nmodule.exports = router\n```\n\n通过调用 `res.render` 函数渲染 ejs 模板，res.render 第一个参数是模板的名字，这里是 users 则会匹配 views/users.ejs，第二个参数是传给模板的数据，这里传入 name，则在 ejs 模板中可使用 name。`res.render` 的作用就是将模板和数据结合生成 html，同时设置响应头中的 `Content-Type: text/html`，告诉浏览器我返回的是 html，不是纯文本，要按 html 展示。现在我们访问 `localhost:3000/users/haha`，如下图所示：\n\n![](./img/3.3.1.png)\n\n上面代码可以看到，我们在模板 `<%= name.toUpperCase() %>` 中使用了 JavaScript 的语法 `.toUpperCase()` 将名字转化为大写，那这个 `<%= xxx %>` 是什么东西呢？ejs 有 3 种常用标签：\n\n1. `<% code %>`：运行 JavaScript 代码，不输出\n2. `<%= code %>`：显示转义后的 HTML内容\n3. `<%- code %>`：显示原始 HTML 内容\n\n> 注意：`<%= code %>` 和 `<%- code %>` 都可以是 JavaScript 表达式生成的字符串，当变量 code 为普通字符串时，两者没有区别。当 code 比如为 `<h1>hello</h1>` 这种字符串时，`<%= code %>` 会原样输出 `<h1>hello</h1>`，而 `<%- code %>` 则会显示 H1 大的 hello 字符串。\n\n下面的例子解释了 `<% code %>` 的用法：\n\n**Data**\n\n```\nsupplies: ['mop', 'broom', 'duster']\n```\n\n**Template**\n\n```ejs\n<ul>\n<% for(var i=0; i<supplies.length; i++) {%>\n   <li><%= supplies[i] %></li>\n<% } %>\n</ul>\n```\n\n**Result**\n\n```html\n<ul>\n  <li>mop</li>\n  <li>broom</li>\n  <li>duster</li>\n</ul>\n```\n\n更多 ejs 的标签请看 [官方文档](https://www.npmjs.com/package/ejs#tags)。\n\n## 3.3.2 includes\n\n我们使用模板引擎通常不是一个页面对应一个模板，这样就失去了模板的优势，而是把模板拆成可复用的模板片段组合使用，如在 views 下新建 header.ejs 和 footer.ejs，并修改 users.ejs：\n\n**views/header.ejs**\n\n```ejs\n<!DOCTYPE html>\n<html>\n  <head>\n    <style type=\"text/css\">\n      body {padding: 50px;font: 14px \"Lucida Grande\", Helvetica, Arial, sans-serif;}\n    </style>\n  </head>\n  <body>\n```\n\n**views/footer.ejs**\n\n```ejs\n  </body>\n</html>\n```\n\n**views/users.ejs**\n\n```ejs\n<%- include('header') %>\n  <h1><%= name.toUpperCase() %></h1>\n  <p>hello, <%= name %></p>\n<%- include('footer') %>\n```\n\n我们将原来的 users.ejs 拆成出了 header.ejs 和 footer.ejs，并在 users.ejs 通过 ejs 内置的 include 方法引入，从而实现了跟以前一个模板文件相同的功能。\n\n> 小提示：拆分模板组件通常有两个好处：\n>\n> 1. 模板可复用，减少重复代码\n> 2. 主模板结构清晰\n\n> 注意：要用 `<%- include('header') %>` 而不是 `<%= include('header') %>`\n\n上一节：[3.2 路由](https://github.com/nswbmw/N-blog/blob/master/book/3.2%20%E8%B7%AF%E7%94%B1.md)\n\n下一节：[3.4 Express 浅析](https://github.com/nswbmw/N-blog/blob/master/book/3.4%20Express%20%E6%B5%85%E6%9E%90.md)"
  },
  {
    "path": "book/3.4 Express 浅析.md",
    "content": "前面我们讲解了 express 中路由和模板引擎 ejs 的用法，但 express 的精髓并不在此，在于中间件的设计理念。\n\n## 3.4.1 中间件与 next\n\nexpress 中的中间件（middleware）就是用来处理请求的，当一个中间件处理完，可以通过调用 `next()` 传递给下一个中间件，如果没有调用 `next()`，则请求不会往下传递，如内置的 `res.render` 其实就是渲染完 html 直接返回给客户端，没有调用 `next()`，从而没有传递给下一个中间件。看个小例子，修改 index.js 如下：\n\n**index.js**\n\n```js\nconst express = require('express')\nconst app = express()\n\napp.use(function (req, res, next) {\n  console.log('1')\n  next()\n})\n\napp.use(function (req, res, next) {\n  console.log('2')\n  res.status(200).end()\n})\n\napp.listen(3000)\n```\n\n此时访问 `localhost:3000`，终端会输出：\n\n```\n1\n2\n```\n\n通过 `app.use` 加载中间件，在中间件中通过 next 将请求传递到下一个中间件，next 可接受一个参数接收错误信息，如果使用了 `next(error)`，则会返回错误而不会传递到下一个中间件，修改 index.js 如下：\n\n**index.js**\n\n```js\nconst express = require('express')\nconst app = express()\n\napp.use(function (req, res, next) {\n  console.log('1')\n  next(new Error('haha'))\n})\n\napp.use(function (req, res, next) {\n  console.log('2')\n  res.status(200).end()\n})\n\napp.listen(3000)\n```\n\n此时访问 `localhost:3000`，终端会输出错误信息：\n\n![](./img/3.4.1.png)\n\n浏览器会显示：\n\n![](./img/3.4.2.png)\n\n> 小提示：`app.use` 有非常灵活的使用方式，详情见 [官方文档](http://expressjs.com/en/4x/api.html#app.use)。\n\nexpress 有成百上千的第三方中间件，在开发过程中我们首先应该去 npm 上寻找是否有类似实现的中间件，尽量避免造轮子，节省开发时间。下面给出几个常用的搜索 npm 模块的网站：\n\n1. [http://npmjs.com](http://npmjs.com)(npm 官网)\n2. [http://node-modules.com](http://node-modules.com)\n3. [https://npms.io](https://npms.io)\n4. [https://nodejsmodules.org](https://nodejsmodules.org)\n\n> 小提示：express@4 之前的版本基于 connect 这个模块实现的中间件的架构，express@4 及以上的版本则移除了对 connect 的依赖自己实现了，理论上基于 connect 的中间件（通常以 `connect-` 开头，如 `connect-mongo`）仍可结合 express 使用。\n\n> 注意：中间件的加载顺序很重要！比如：通常把日志中间件放到比较靠前的位置，后面将会介绍的 `connect-flash` 中间件是基于 session 的，所以需要在 `express-session` 后加载。\n\n## 3.4.2 错误处理\n\n上面的例子中，应用程序为我们自动返回了错误栈信息（express 内置了一个默认的错误处理器），假如我们想手动控制返回的错误内容，则需要加载一个自定义错误处理的中间件，修改 index.js 如下：\n\n**index.js**\n\n```js\nconst express = require('express')\nconst app = express()\n\napp.use(function (req, res, next) {\n  console.log('1')\n  next(new Error('haha'))\n})\n\napp.use(function (req, res, next) {\n  console.log('2')\n  res.status(200).end()\n})\n\n//错误处理\napp.use(function (err, req, res, next) {\n  console.error(err.stack)\n  res.status(500).send('Something broke!')\n})\n\napp.listen(3000)\n```\n\n此时访问 `localhost:3000`，浏览器会显示 `Something broke!`。\n\n> 小提示：关于 express 的错误处理，详情见 [官方文档](http://expressjs.com/en/guide/error-handling.html)。\n\n上一节：[3.3 模板引擎](https://github.com/nswbmw/N-blog/blob/master/book/3.3%20%E6%A8%A1%E6%9D%BF%E5%BC%95%E6%93%8E.md)\n\n下一节：[4.1 开发环境](https://github.com/nswbmw/N-blog/blob/master/book/4.1%20%E5%BC%80%E5%8F%91%E7%8E%AF%E5%A2%83.md)"
  },
  {
    "path": "book/4.1 开发环境.md",
    "content": "从本章开始，正式学习如何使用 Express + MongoDB 搭建一个博客。\n\n#### Node.js: `8.9.1`\n#### MongoDB: `3.4.10`\n#### Express: `4.16.2`\n\n上一节：[3.4 Express 浅析](https://github.com/nswbmw/N-blog/blob/master/book/3.4%20Express%20%E6%B5%85%E6%9E%90.md)\n\n下一节：[4.2 准备工作](https://github.com/nswbmw/N-blog/blob/master/book/4.2%20%E5%87%86%E5%A4%87%E5%B7%A5%E4%BD%9C.md)"
  },
  {
    "path": "book/4.10 留言.md",
    "content": "## 4.10.1 留言模型设计\n\n我们只需要留言的作者 id、留言内容和关联的文章 id 这几个字段，修改 lib/mongo.js，添加如下代码：\n\n**lib/mongo.js**\n\n```js\nexports.Comment = mongolass.model('Comment', {\n  author: { type: Mongolass.Types.ObjectId, required: true },\n  content: { type: 'string', required: true },\n  postId: { type: Mongolass.Types.ObjectId, required: true }\n})\nexports.Comment.index({ postId: 1, _id: 1 }).exec()// 通过文章 id 获取该文章下所有留言，按留言创建时间升序\n```\n\n## 4.10.2 显示留言\n\n在实现留言功能之前，我们先让文章页可以显示留言列表。首先创建留言的模板，新建 views/components/comments.ejs，添加如下代码：\n\n**views/components/comments.ejs**\n\n```ejs\n<div class=\"ui grid\">\n  <div class=\"four wide column\"></div>\n  <div class=\"eight wide column\">\n    <div class=\"ui segment\">\n      <div class=\"ui minimal comments\">\n        <h3 class=\"ui dividing header\">留言</h3>\n\n        <% comments.forEach(function (comment) { %>\n          <div class=\"comment\">\n            <span class=\"avatar\">\n              <img src=\"/img/<%= comment.author.avatar %>\">\n            </span>\n            <div class=\"content\">\n              <a class=\"author\" href=\"/posts?author=<%= comment.author._id %>\"><%= comment.author.name %></a>\n              <div class=\"metadata\">\n                <span class=\"date\"><%= comment.created_at %></span>\n              </div>\n              <div class=\"text\"><%- comment.content %></div>\n\n              <% if (user && comment.author._id && user._id.toString() === comment.author._id.toString()) { %>\n                <div class=\"actions\">\n                  <a class=\"reply\" href=\"/comments/<%= comment._id %>/remove\">删除</a>\n                </div>\n              <% } %>\n            </div>\n          </div>\n        <% }) %>\n\n        <% if (user) { %>\n          <form class=\"ui reply form\" method=\"post\" action=\"/comments\">\n            <input name=\"postId\" value=\"<%= post._id %>\" hidden>\n            <div class=\"field\">\n              <textarea name=\"content\"></textarea>\n            </div>\n            <input type=\"submit\" class=\"ui icon button\" value=\"留言\" />\n          </form>\n        <% } %>\n\n      </div>\n    </div>\n  </div>\n</div>\n```\n\n> 注意：我们在提交留言表单时带上了文章 id（postId），通过 hidden 隐藏。\n\n在文章页引入留言的模板片段，修改 views/post.ejs 为：\n\n**views/post.ejs**\n\n```ejs\n<%- include('header') %>\n\n<%- include('components/post-content') %>\n<%- include('components/comments') %>\n\n<%- include('footer') %>\n```\n\n新建 models/comments.js，存放留言相关的数据库操作，添加如下代码：\n\n**models/comments.js**\n\n```js\nconst marked = require('marked')\nconst Comment = require('../lib/mongo').Comment\n\n// 将 comment 的 content 从 markdown 转换成 html\nComment.plugin('contentToHtml', {\n  afterFind: function (comments) {\n    return comments.map(function (comment) {\n      comment.content = marked(comment.content)\n      return comment\n    })\n  }\n})\n\nmodule.exports = {\n  // 创建一个留言\n  create: function create (comment) {\n    return Comment.create(comment).exec()\n  },\n\n  // 通过留言 id 获取一个留言\n  getCommentById: function getCommentById (commentId) {\n    return Comment.findOne({ _id: commentId }).exec()\n  },\n\n  // 通过留言 id 删除一个留言\n  delCommentById: function delCommentById (commentId) {\n    return Comment.deleteOne({ _id: commentId }).exec()\n  },\n\n  // 通过文章 id 删除该文章下所有留言\n  delCommentsByPostId: function delCommentsByPostId (postId) {\n    return Comment.deleteMany({ postId: postId }).exec()\n  },\n\n  // 通过文章 id 获取该文章下所有留言，按留言创建时间升序\n  getComments: function getComments (postId) {\n    return Comment\n      .find({ postId: postId })\n      .populate({ path: 'author', model: 'User' })\n      .sort({ _id: 1 })\n      .addCreatedAt()\n      .contentToHtml()\n      .exec()\n  },\n\n  // 通过文章 id 获取该文章下留言数\n  getCommentsCount: function getCommentsCount (postId) {\n    return Comment.count({ postId: postId }).exec()\n  }\n}\n```\n\n> 小提示：我们让留言也支持了 markdown。\n> 注意：删除一篇文章成功后也要删除该文章下所有的评论，上面 delCommentsByPostId 就是用来做这件事的。\n\n\n修改 models/posts.js，在：\n\n**models/posts.js**\n\n```js\nconst Post = require('../lib/mongo').Post\n```\n\n下添加如下代码：\n\n```js\nconst CommentModel = require('./comments')\n\n// 给 post 添加留言数 commentsCount\nPost.plugin('addCommentsCount', {\n  afterFind: function (posts) {\n    return Promise.all(posts.map(function (post) {\n      return CommentModel.getCommentsCount(post._id).then(function (commentsCount) {\n        post.commentsCount = commentsCount\n        return post\n      })\n    }))\n  },\n  afterFindOne: function (post) {\n    if (post) {\n      return CommentModel.getCommentsCount(post._id).then(function (count) {\n        post.commentsCount = count\n        return post\n      })\n    }\n    return post\n  }\n})\n```\n\n在 PostModel 上注册了 `addCommentsCount` 用来给每篇文章添加留言数 `commentsCount`，在 `getPostById` 和 `getPosts` 方法里的：\n\n```\n.addCreatedAt()\n```\n\n下添加：\n\n```\n.addCommentsCount()\n```\n\n这样主页和文章页的文章就可以正常显示留言数了。\n\n然后将 `delPostById` 修改为：\n\n```js\n// 通过用户 id 和文章 id 删除一篇文章\ndelPostById: function delPostById (postId, author) {\n  return Post.deleteOne({ author: author, _id: postId })\n    .exec()\n    .then(function (res) {\n      // 文章删除后，再删除该文章下的所有留言\n      if (res.result.ok && res.result.n > 0) {\n        return CommentModel.delCommentsByPostId(postId)\n      }\n    })\n}\n```\n\n> 小提示：虽然目前看起来使用 Mongolass 自定义插件并不能节省代码，反而使代码变多了。Mongolass 插件真正的优势在于：在项目非常庞大时，可通过自定义的插件随意组合（及顺序）实现不同的输出，如上面的 `getPostById` 需要将取出 markdown 转换成 html，则使用 `.contentToHtml()`，否则像 `getRawPostById` 则不必使用。\n\n修改 routes/posts.js，在：\n\n**routes/posts.js**\n\n```js\nconst PostModel = require('../models/posts')\n```\n\n下引入 CommentModel:\n\n```js\nconst CommentModel = require('../models/comments')\n```\n\n在文章页传入留言列表，将：\n\n```js\n// GET /posts/:postId 单独一篇的文章页\nrouter.get('/:postId', function (req, res, next) {\n  ...\n})\n```\n\n修改为：\n\n```js\n// GET /posts/:postId 单独一篇的文章页\nrouter.get('/:postId', function (req, res, next) {\n  const postId = req.params.postId\n\n  Promise.all([\n    PostModel.getPostById(postId), // 获取文章信息\n    CommentModel.getComments(postId), // 获取该文章所有留言\n    PostModel.incPv(postId)// pv 加 1\n  ])\n    .then(function (result) {\n      const post = result[0]\n      const comments = result[1]\n      if (!post) {\n        throw new Error('该文章不存在')\n      }\n\n      res.render('post', {\n        post: post,\n        comments: comments\n      })\n    })\n    .catch(next)\n})\n```\n\n现在刷新文章页试试吧，此时已经显示了留言的输入框。\n\n## 4.10.3 发表与删除留言\n\n现在我们来实现发表与删除留言的功能。将 routes/comments.js 修改如下：\n\n```js\nconst express = require('express')\nconst router = express.Router()\n\nconst checkLogin = require('../middlewares/check').checkLogin\nconst CommentModel = require('../models/comments')\n\n// POST /comments 创建一条留言\nrouter.post('/', checkLogin, function (req, res, next) {\n  const author = req.session.user._id\n  const postId = req.fields.postId\n  const content = req.fields.content\n\n  // 校验参数\n  try {\n    if (!content.length) {\n      throw new Error('请填写留言内容')\n    }\n  } catch (e) {\n    req.flash('error', e.message)\n    return res.redirect('back')\n  }\n\n  const comment = {\n    author: author,\n    postId: postId,\n    content: content\n  }\n\n  CommentModel.create(comment)\n    .then(function () {\n      req.flash('success', '留言成功')\n      // 留言成功后跳转到上一页\n      res.redirect('back')\n    })\n    .catch(next)\n})\n\n// GET /comments/:commentId/remove 删除一条留言\nrouter.get('/:commentId/remove', checkLogin, function (req, res, next) {\n  const commentId = req.params.commentId\n  const author = req.session.user._id\n\n  CommentModel.getCommentById(commentId)\n    .then(function (comment) {\n      if (!comment) {\n        throw new Error('留言不存在')\n      }\n      if (comment.author.toString() !== author.toString()) {\n        throw new Error('没有权限删除留言')\n      }\n      CommentModel.delCommentById(commentId)\n        .then(function () {\n          req.flash('success', '删除留言成功')\n          // 删除成功后跳转到上一页\n          res.redirect('back')\n        })\n        .catch(next)\n    })\n})\n\nmodule.exports = router\n```\n\n至此，我们完成了创建留言和删除留言的逻辑。刷新页面，尝试留言试试吧。留言成功后，将鼠标悬浮在留言上可以显示出 `删除` 的按钮，点击可以删除留言。\n\n上一节：[4.9 文章](https://github.com/nswbmw/N-blog/blob/master/book/4.9%20%E6%96%87%E7%AB%A0.md)\n\n下一节：[4.11 404页面](https://github.com/nswbmw/N-blog/blob/master/book/4.11%20404%20%E9%A1%B5%E9%9D%A2.md)\n"
  },
  {
    "path": "book/4.11 404 页面.md",
    "content": "现在访问一个不存在的地址，如：`http://localhost:3000/haha` 页面会显示：\n\n```\nCannot GET /haha\n```\n\n我们来自定义 404 页面。修改 routes/index.js，在：\n\n**routes/index.js**\n\n```js\napp.use('/comments', require('./comments'))\n```\n\n下添加如下代码：\n\n```js\n// 404 page\napp.use(function (req, res) {\n  if (!res.headersSent) {\n    res.status(404).render('404')\n  }\n})\n```\n\n新建 views/404.ejs，添加如下代码：\n\n**views/404.ejs**\n\n```ejs\n<!DOCTYPE html>\n<html>\n  <head>\n    <meta charset=\"utf-8\">\n    <title><%= blog.title %></title>\n    <script type=\"text/javascript\" src=\"http://www.qq.com/404/search_children.js\" charset=\"utf-8\"></script>\n  </head>\n  <body></body>\n</html>\n```\n\n这里我们只为了演示 express 中处理 404 的情况，用了腾讯公益的 404 页面，刷新一下页面看下效果吧。\n\n上一节：[4.10 留言](https://github.com/nswbmw/N-blog/blob/master/book/4.10%20%E7%95%99%E8%A8%80.md)\n\n下一节：[4.12 错误页面](https://github.com/nswbmw/N-blog/blob/master/book/4.12%20%E9%94%99%E8%AF%AF%E9%A1%B5%E9%9D%A2.md)\n"
  },
  {
    "path": "book/4.12 错误页面.md",
    "content": "前面讲到 express 有一个内置的错误处理逻辑，如果程序出错，会直接将错误栈返回并显示到页面上。如访问：`localhost:3000/posts/xxx/edit` 没有权限编辑的文章页，将会直接在页面中显示错误栈，如下：\n\n```js\nError: 权限不足\n    at /Users/nswbmw/Desktop/myblog/routes/posts.js:95:15\n    at <anonymous>\n    at process._tickCallback (internal/process/next_tick.js:188:7)\n```\n\n现在我们修改代码，实现复用页面通知。修改 index.js，在 `app.listen` 上面添加如下代码：\n\n**index.js**\n\n```js\napp.use(function (err, req, res, next) {\n  console.error(err)\n  req.flash('error', err.message)\n  res.redirect('/posts')\n})\n```\n\n这里我们实现了将错误信息用页面通知展示的功能，刷新页面将会跳转到主页并显示『权限不足』的红色通知。\n\n上一节：[4.11 404页面](https://github.com/nswbmw/N-blog/blob/master/book/4.11%20404%20%E9%A1%B5%E9%9D%A2.md)\n\n下一节：[4.13 日志](https://github.com/nswbmw/N-blog/blob/master/book/4.13%20%E6%97%A5%E5%BF%97.md)\n"
  },
  {
    "path": "book/4.13 日志.md",
    "content": "现在我们来实现日志功能，日志分为正常请求的日志和错误请求的日志，我们希望实现这两种日志都打印到终端并写入文件。\n\n## 4.13.1 winston 和 express-winston\n\n我们使用 [winston](https://www.npmjs.com/package/winston) 和 [express-winston](https://www.npmjs.com/package/express-winston) 记录日志。\n\n新建 logs 目录存放日志文件，修改 index.js，在：\n\n**index.js**\n\n```js\nconst pkg = require('./package')\n```\n\n下引入所需模块：\n\n```js\nconst winston = require('winston')\nconst expressWinston = require('express-winston')\n```\n\n将：\n\n```\n// 路由\nroutes(app)\n```\n\n修改为：\n\n```js\n// 正常请求的日志\napp.use(expressWinston.logger({\n  transports: [\n    new (winston.transports.Console)({\n      json: true,\n      colorize: true\n    }),\n    new winston.transports.File({\n      filename: 'logs/success.log'\n    })\n  ]\n}))\n// 路由\nroutes(app)\n// 错误请求的日志\napp.use(expressWinston.errorLogger({\n  transports: [\n    new winston.transports.Console({\n      json: true,\n      colorize: true\n    }),\n    new winston.transports.File({\n      filename: 'logs/error.log'\n    })\n  ]\n}))\n```\n\n刷新页面看一下终端输出及 logs 下的文件。\n可以看出：winston 将正常请求的日志打印到终端并写入了 `logs/success.log`，将错误请求的日志打印到终端并写入了 `logs/error.log`。\n\n> 注意：记录正常请求日志的中间件要放到 `routes(app)` 之前，记录错误请求日志的中间件要放到 `routes(app)` 之后。\n\n## 4.13.2 .gitignore\n\n如果我们想把项目托管到 git 服务器上（如: [GitHub](https://github.com)），而不想把线上配置、本地调试的 logs 以及 node_modules 添加到 git 的版本控制中，这个时候就需要 .gitignore 文件了，git 会读取 .gitignore 并忽略这些文件。在 myblog 下新建 .gitignore 文件，添加如下配置：\n\n**.gitignore**\n\n```\nconfig/*\n!config/default.*\nnpm-debug.log\nnode_modules\ncoverage\n```\n\n需要注意的是，通过设置：\n\n```\nconfig/*\n!config/default.*\n```\n\n这样只有 config/default.js 会加入 git 的版本控制，而 config 目录下的其他配置文件则会被忽略，因为把线上配置加入到 git 是一个不安全的行为，通常你需要本地或者线上环境手动创建 config/production.js，然后添加一些线上的配置（如：mongodb 配置）即可覆盖相应的 default 配置。\n\n> 注意：后面讲到部署到 Heroku 时，因为无法登录到 Heroku 主机，所以可以把以下两行删掉，将 config/production.js 也加入 git 中。\n> \n> ```\n> config/*\n> !config/default.*\n> ```\n\n然后在 public/img 目录下创建 .gitignore：\n\n```\n# Ignore everything in this directory\n*\n# Except this file\n!.gitignore\n```\n\n这样 git 会忽略 public/img 目录下所有上传的头像，而不忽略 public/img 目录。同理，在 logs 目录下创建 .gitignore 忽略日志文件：\n\n```\n# Ignore everything in this directory\n*\n# Except this file\n!.gitignore\n```\n\n上一节：[4.12 错误页面](https://github.com/nswbmw/N-blog/blob/master/book/4.12%20%E9%94%99%E8%AF%AF%E9%A1%B5%E9%9D%A2.md)\n\n下一节：[4.14 测试](https://github.com/nswbmw/N-blog/blob/master/book/4.14%20%E6%B5%8B%E8%AF%95.md)"
  },
  {
    "path": "book/4.14 测试.md",
    "content": "## 4.14.1 mocha 和 supertest\n\n[mocha](https://www.npmjs.com/package/mocha) 和 [supertest](https://www.npmjs.com/package/supertest) 是常用的测试组合，通常用来测试 restful 的 api 接口，这里我们也可以用来测试我们的博客应用。\n在 myblog 下新建 test 文件夹存放测试文件，以注册为例讲解 mocha 和 supertest 的用法。首先安装所需模块：\n\n```sh\nnpm i mocha supertest --save-dev\n```\n\n修改 package.json，将：\n\n**package.json**\n\n```json\n\"scripts\": {\n  \"test\": \"echo \\\"Error: no test specified\\\" && exit 1\"\n}\n```\n\n修改为：\n\n```json\n\"scripts\": {\n  \"test\": \"mocha test\"\n}\n```\n\n指定执行 test 目录的测试。修改 index.js，将：\n\n**index.js**\n\n```js\n// 监听端口，启动程序\napp.listen(config.port, function () {\n  console.log(`${pkg.name} listening on port ${config.port}`)\n})\n```\n\n修改为:\n\n```js\nif (module.parent) {\n  // 被 require，则导出 app\n  module.exports = app\n} else {\n  // 监听端口，启动程序\n  app.listen(config.port, function () {\n    console.log(`${pkg.name} listening on port ${config.port}`)\n  })\n}\n```\n\n这样做可以实现：直接启动 index.js 则会监听端口启动程序，如果 index.js 被 require 了，则导出 app，通常用于测试。\n\n找一张图片用于测试上传头像，放到 test 目录下，如 avatar.png。新建 test/signup.js，添加如下测试代码：\n\n**test/signup.js**\n\n```js\nconst path = require('path')\nconst assert = require('assert')\nconst request = require('supertest')\nconst app = require('../index')\nconst User = require('../lib/mongo').User\n\nconst testName1 = 'testName1'\nconst testName2 = 'nswbmw'\ndescribe('signup', function () {\n  describe('POST /signup', function () {\n    const agent = request.agent(app)// persist cookie when redirect\n    beforeEach(function (done) {\n      // 创建一个用户\n      User.create({\n        name: testName1,\n        password: '123456',\n        avatar: '',\n        gender: 'x',\n        bio: ''\n      })\n        .exec()\n        .then(function () {\n          done()\n        })\n        .catch(done)\n    })\n\n    afterEach(function (done) {\n      // 删除测试用户\n      User.deleteMany({ name: { $in: [testName1, testName2] } })\n        .exec()\n        .then(function () {\n          done()\n        })\n        .catch(done)\n    })\n\n    after(function (done) {\n      process.exit()\n    })\n\n    // 用户名错误的情况\n    it('wrong name', function (done) {\n      agent\n        .post('/signup')\n        .type('form')\n        .field({ name: '' })\n        .attach('avatar', path.join(__dirname, 'avatar.png'))\n        .redirects()\n        .end(function (err, res) {\n          if (err) return done(err)\n          assert(res.text.match(/名字请限制在 1-10 个字符/))\n          done()\n        })\n    })\n\n    // 性别错误的情况\n    it('wrong gender', function (done) {\n      agent\n        .post('/signup')\n        .type('form')\n        .field({ name: testName2, gender: 'a' })\n        .attach('avatar', path.join(__dirname, 'avatar.png'))\n        .redirects()\n        .end(function (err, res) {\n          if (err) return done(err)\n          assert(res.text.match(/性别只能是 m、f 或 x/))\n          done()\n        })\n    })\n    // 其余的参数测试自行补充\n    // 用户名被占用的情况\n    it('duplicate name', function (done) {\n      agent\n        .post('/signup')\n        .type('form')\n        .field({ name: testName1, gender: 'm', bio: 'noder', password: '123456', repassword: '123456' })\n        .attach('avatar', path.join(__dirname, 'avatar.png'))\n        .redirects()\n        .end(function (err, res) {\n          if (err) return done(err)\n          assert(res.text.match(/用户名已被占用/))\n          done()\n        })\n    })\n\n    // 注册成功的情况\n    it('success', function (done) {\n      agent\n        .post('/signup')\n        .type('form')\n        .field({ name: testName2, gender: 'm', bio: 'noder', password: '123456', repassword: '123456' })\n        .attach('avatar', path.join(__dirname, 'avatar.png'))\n        .redirects()\n        .end(function (err, res) {\n          if (err) return done(err)\n          assert(res.text.match(/注册成功/))\n          done()\n        })\n    })\n  })\n})\n```\n\n此时编辑器会报语法错误（如：describe 未定义等等），修改 .eslintrc.json 如下：\n\n```json\n{\n  \"extends\": \"standard\",\n  \"globals\": {\n    \"describe\": true,\n    \"beforeEach\": true,\n    \"afterEach\": true,\n    \"after\": true,\n    \"it\": true\n  }\n}\n```\n\n这样，eslint 会忽略 globals 中变量未定义的警告。运行 `npm test` 看看效果吧，其余的测试请读者自行完成。\n\n## 4.14.2 测试覆盖率\n\n我们写测试肯定想覆盖所有的情况（包括各种出错的情况及正确时的情况），但光靠想需要写哪些测试是不行的，总也会有疏漏，最简单的办法就是可以直观的看出测试是否覆盖了所有的代码，这就是测试覆盖率，即被测试覆盖到的代码行数占总代码行数的比例。\n\n> 注意：即使测试覆盖率达到 100% 也不能说明你的测试覆盖了所有的情况，只能说明基本覆盖了所有的情况。\n\n[istanbul](https://www.npmjs.com/package/istanbul) 是一个常用的生成测试覆盖率的库，它会将测试的结果报告生成 html 页面，并放到项目根目录的 coverage 目录下。首先安装 istanbul:\n\n```\nnpm i istanbul --save-dev\n```\n\n配置 istanbul 很简单，将 package.json 中：\n\n**package.json**\n\n```json\n\"scripts\": {\n  \"test\": \"mocha test\"\n}\n```\n\n修改为：\n\n```json\n\"scripts\": {\n  \"test\": \"istanbul cover _mocha\"\n}\n```\n\n**注意**：Windows 下需要改成 `istanbul cover node_modules/mocha/bin/_mocha`。\n\n即可将 istanbul 和 mocha 结合使用，运行 `npm test` 终端会打印：\n\n![](./img/4.14.1.png)\n\n打开 myblog/coverage/Icov-report/index.html，如下所示：\n\n![](./img/4.14.2.png)\n\n可以点进去查看某个代码文件具体的覆盖率，如下所示：\n\n![](./img/4.14.3.png)\n\n红色的行表示测试没有覆盖到，因为我们只写了 name 和 gender 的测试。\n\n上一节：[4.13 日志](https://github.com/nswbmw/N-blog/blob/master/book/4.13%20%E6%97%A5%E5%BF%97.md)\n\n下一节：[4.15 部署](https://github.com/nswbmw/N-blog/blob/master/book/4.15%20%E9%83%A8%E7%BD%B2.md)\n"
  },
  {
    "path": "book/4.15 部署.md",
    "content": "## 4.15.1 申请 MLab\n\n[MLab](https://mlab.com) (前身是 MongoLab) 是一个 mongodb 云数据库提供商，我们可以选择 500MB 空间的免费套餐用来测试。注册成功后，点击右上角的 `Create New` 创建一个数据库（如: myblog），成功后点击进入到该数据库详情页，注意页面中有一行黄色的警告：\n\n```\nA database user is required to connect to this database. To create one now, visit the 'Users' tab and click the 'Add database user' button.\n```\n\n每个数据库至少需要一个 user，所以我们点击 Users 下的 `Add database user` 创建一个用户。\n\n> 注意：不要选中 `Make read-only`，因为我们有写数据库的操作。\n\n最后分配给我们的类似下面的 mongodb url：\n\n```\nmongodb://<dbuser>:<dbpassword>@ds139327.mlab.com:39327/myblog\n```\n\n如我创建的用户名和密码都为 myblog 的用户，新建 config/production.js，添加如下代码：\n\n**config/production.js**\n\n```js\nmodule.exports = {\n  mongodb: 'mongodb://myblog:myblog@ds139327.mlab.com:39327/myblog'\n}\n```\n\n停止程序，然后以 production 配置启动程序:\n\n```sh\nnpm i cross-env --save-dev # 本地安装 cross-env\nnpm i cross-env -g # 全局安装 cross-env\ncross-env NODE_ENV=production supervisor index\n```\n\n> 注意：cross-env 用来兼容 Windows 系统和 Linux/Mac 系统设置环境变量的差异。\n\n## 4.15.2 pm2\n\n当我们的博客要部署到线上服务器时，不能单纯的靠 `node index` 或者 `supervisor index` 来启动了，因为我们断掉 SSH 连接后服务就终止了，这时我们就需要像 [pm2](https://www.npmjs.com/package/pm2) 或者 [forever](https://www.npmjs.com/package/forever) 这样的进程管理工具了。pm2 是 Node.js 下的生产环境进程管理工具，就是我们常说的进程守护工具，可以用来在生产环境中进行自动重启、日志记录、错误预警等等。以 pm2 为例，全局安装 pm2：\n\n```sh\nnpm i pm2 -g\n```\n\n修改 package.json，添加 start 的命令：\n\n**package.json**\n\n```json\n\"scripts\": {\n  \"test\": \"istanbul cover _mocha\",\n  \"start\": \"cross-env NODE_ENV=production pm2 start index.js --name 'myblog'\"\n}\n```\n\n然后运行 `npm start` 通过 pm2 启动程序，如下图所示 ：\n\n![](./img/4.15.1.png)\n\npm2 常用命令:\n\n1. `pm2 start/stop`: 启动/停止程序\n2. `pm2 reload/restart [id|name]`: 重启程序\n3. `pm2 logs [id|name]`: 查看日志\n4. `pm2 l/list`: 列出程序列表\n\n更多命令请使用 `pm2 -h` 查看。\n\n## 4.15.2 部署到 Heroku\n\n[Heroku](https://www.heroku.com) 是一个支持多种编程语言的云服务平台，Heroku 也提供免费的基础套餐供开发者测试使用。现在，我们将论坛部署到 Heroku。\n\n> 注意：新版 heroku 会有填写信用卡的步骤，如果没有信用卡请跳过本节。\n\n首先，需要到 [https://toolbelt.heroku.com/](https://toolbelt.heroku.com/) 下载安装 Heroku 的命令行工具包 toolbelt。然后登录（如果没有账号，请注册）到 Heroku 的 Dashboard，点击右上角 New -> Create New App 创建一个应用。创建成功后运行：\n\n```sh\nheroku login\n```\n\n填写正确的 email 和 password 验证通过后，本地会产生一个 SSH public key。在部署到 Heroku 之前，我们需要对代码进行简单的修改。如下：\n\n1.删掉 .gitignore 中：\n```\nconfig/*\n!config/default.*\n```\n因为我们无法登录到 Heroku 主机创建 production 配置文件，所以这里将 production 配置也上传到 Heroku。\n\n2.打开 index.js，将 `app.listen` 修改为：\n```js\nconst port = process.env.PORT || config.port\napp.listen(port, function () {\n  console.log(`${pkg.name} listening on port ${port}`)\n})\n```\n因为 Heroku 会动态分配端口（通过环境变量 PORT 指定），所以不能用配置文件里写死的端口。\n\n3.修改 package.json，在 scripts 添加：\n\n```json\n\"heroku\": \"NODE_ENV=production node index\"\n```\n\n在根目录下新建 Procfile 文件，添加如下内容：\n```\nweb: npm run heroku\n```\nProcfile 文件告诉 Heroku 该使用什么命令启动一个 web 服务。更多信息见：[https://devcenter.heroku.com/articles/getting-started-with-nodejs](https://devcenter.heroku.com/articles/getting-started-with-nodejs)。\n\n然后输入以下命令：\n\n```sh\ngit init\nheroku git:remote -a 你的应用名称\ngit add .\ngit commit -am \"init\"\ngit push heroku master\n```\n\n稍后，我们的论坛就部署成功了。使用：\n\n```sh\nheroku open\n```\n\n打开应用主页。如果出现 \"Application error\"，使用：\n\n```sh\nheroku logs\n```\n查看日志，调试完后 commit 并 push 到 heroku重新部署。\n\n## 4.15.3 部署到 UCloud\n\n### 创建主机\n\n1. 注册 UCloud\n2. 点击左侧的 `云主机`，然后点击 `创建主机`，统统选择最低配置\n3. 右侧付费方式选择 `按时`（每小时），点击 `立即购买`\n4. 在支付确认页面，点击 `确认支付`\n\n购买成功后回到主机管理列表，如下所示：\n\n![](./img/4.15.2.png)\n\n> 注意：下面所有的 ip 都替换为你自己的外网 ip。\n\n### 环境搭建与部署\n\n修改 config/production.js，将 port 修改为 80 端口：\n\n**config/production.js**\n\n```js\nmodule.exports = {\n  port: 80,\n  mongodb: 'mongodb://myblog:myblog@ds139327.mlab.com:39327/myblog'\n}\n```\n\n登录主机，用刚才设置的密码：\n\n```sh\nssh root@106.75.47.229\n```\n\n因为是 CentOS 系统，所以我选择使用 yum 安装，而不是下载源码编译安装：\n\n```sh\nyum install git #安装git\nyum install nodejs #安装 Node.js\nyum install npm #安装 npm\n\nnpm i npm -g #升级 npm\nnpm i pm2 -g #安装 pm2\nnpm i n -g #安装 n\nn v8.9.1 #安装 v8.9.1 版本的 Node.js\nn use 8.9.1 #使用 v8.9.1 版本的 Node.js\nnode -v\n```\n> 注意：如果 `node -v` 显示的不是 8.9.1，则断开 ssh，重新登录主机再试试。\n\n此时应该在 `/root` 目录下，运行以下命令：\n```sh\ngit clone https://github.com/nswbmw/N-blog.git myblog #或在本机 myblog 目录下运行 rsync -av --exclude=\"node_modules\" ./ root@106.75.47.229:/root/myblog\ncd myblog\nnpm i\nnpm start\npm2 logs\n```\n> 注意：如果不想用 git 的形式将代码拉到云主机上，可以用 rsync 将本地的代码同步到你的 UCloud 主机上，如上所示。\n\n最后，访问你的公网 ip 地址试试吧，如下所示：\n\n![](./img/4.15.3.png)\n\n> 小提示：因为我们选择的按时付费套餐，测试完成后，可在主机管理页面选择关闭主机，节约费用。\n\n## 4.15.4 部署到阿里云\n\n### 创建主机\n\n1. 注册/登录\n2. 充值 100（因为我们选择『按量付费』，阿里云要求最低账户余额 >= 100）\n3. 进入『云服务器 ECS』\n4. 点击『创建实例』\n\n进入创建实例页面，按下图选择配置：\n\n![](./img/4.15.4.png)\n\n需要注意几点：\n\n1. 计费方式：按量付费\n2. 公网 ip 地址：分配\n3. 安全组：选中开启 80 端口\n4. 镜像：Ubuntu 16.04 64位\n\n点击『开通进入下一页』，选中：\n\n![](./img/4.15.5.png)\n\n> 注意：这里我们只是演示，所以自动释放时间只设置了几个小时\n\n点击『去开通』创建成功，然后点击提示中的『管理控制台』进入 ECS 管理页，刚才创建的机器需要等待几分钟才会初始化成功。成功后如下所示：\n\n![](./img/4.15.6.png)\n\n### 环境搭建\n\n复制创建的机器的公网 ip 地址，运行：\n\n```sh\nssh root@39.106.134.66\n```\n\n输入刚才设置的密码登录远程主机。\n\n#### 安装 Node.js\n\n我们下载编译好的 Node.js 压缩包，解压然后使用软连接。\n\n```sh\nwget https://nodejs.org/dist/v8.9.1/node-v8.9.1-linux-x64.tar.xz\ntar -xvf node-v8.9.1-linux-x64.tar.xz\nmv node-v8.9.1-linux-x64 nodejs\nln -s ~/nodejs/bin/* /usr/local/bin/\nnode -v\nnpm -v\n```\n\n#### 安装 MongoDB\n\n```sh\nwget https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-ubuntu1604-3.4.10.tgz\ntar -xvf mongodb-linux-x86_64-ubuntu1604-3.4.10.tgz\nmv mongodb-linux-x86_64-ubuntu1604-3.4.10 mongodb\nln -s ~/mongodb/bin/* /usr/local/bin/\nmongod --version\nmongo --version\nmkdir mongodb/data\nmongod --dbpath=mongodb/data &\n```\n\n#### 安装 Git\n\n```sh\napt-get update\napt-get install git\ngit clone https://github.com/nswbmw/N-blog.git #或者你的 GitHub blog 地址\ncd N-blog\nnpm i\nvim config/default.js #修改端口 3000->80\nnode index\n```\n\n此时，浏览器中访问你的机器的公网 ip 试试吧。\n\n#### 使用 PM2 启动\n\n```sh\nnpm i pm2 -g\nln -s ~/nodejs/bin/* /usr/local/bin/\npm2 start index.js --name=\"myblog\"\n```\n\n这里我们使用 pm2 启动博客，所以关掉终端后博客仍然在运行。\n\n上一节：[4.14 测试](https://github.com/nswbmw/N-blog/blob/master/book/4.14%20%E6%B5%8B%E8%AF%95.md)\n"
  },
  {
    "path": "book/4.2 准备工作.md",
    "content": "## 4.2.1 目录结构\n\n我们停止 supervisor 并删除 myblog 目录从头来过。重新创建 myblog，运行 `npm init`，如下：\n\n![](./img/4.2.1.png)\n\n在 myblog 目录下创建以下目录及空文件（package.json 除外）：\n\n![](./img/4.2.2.png)\n\n对应文件及文件夹的用处：\n\n1. `models`: 存放操作数据库的文件\n2. `public`: 存放静态文件，如样式、图片等\n3. `routes`: 存放路由文件\n4. `views`: 存放模板文件\n5. `index.js`: 程序主文件\n6. `package.json`: 存储项目名、描述、作者、依赖等等信息\n\n> 小提示：不知读者发现了没有，我们遵循了 MVC（模型(model)－视图(view)－控制器(controller/route)） 的开发模式。\n\n## 4.2.2 安装依赖模块 \n\n运行以下命令安装所需模块：\n\n```sh\nnpm i config-lite connect-flash connect-mongo ejs express express-session marked moment mongolass objectid-to-timestamp sha1 winston express-winston --save\nnpm i https://github.com:utatti/express-formidable.git --save # 从 GitHub 安装 express-formidable 最新版，v1.0.0 有 bug\n```\n\n对应模块的用处：\n\n1. `express`: web 框架\n2. `express-session`: session 中间件\n3. `connect-mongo`: 将 session 存储于 mongodb，结合 express-session 使用\n4. `connect-flash`: 页面通知的中间件，基于 session 实现\n5. `ejs`: 模板\n6. `express-formidable`: 接收表单及文件上传的中间件\n7. `config-lite`: 读取配置文件\n8. `marked`: markdown 解析\n9. `moment`: 时间格式化\n10. `mongolass`: mongodb 驱动\n11. `objectid-to-timestamp`: 根据 ObjectId 生成时间戳\n12. `sha1`: sha1 加密，用于密码加密\n13. `winston`: 日志\n14. `express-winston`: express 的 winston 日志中间件\n\n后面会详细讲解这些模块的用法。\n\n## 4.2.3 ESLint\n\nESLint 是一个代码规范和语法错误检查工具。使用 ESLint 可以规范我们的代码书写，可以在编写代码期间就能发现一些低级错误。\n\nESLint 需要结合编辑器或 IDE 使用，如：\n\n- Sublime Text 需要装两个插件：SublimeLinter + SublimeLinter-contrib-eslint\n- VS Code 需要装一个插件：ESLint\n\n> 小提示：Sublime Text 安装插件通过 ctrl+shift+p 调出 Package Control，输入 install 选择 Install Package 回车。输入对应插件名搜索，回车安装。\n> 小提示：VS Code 安装插件需要点击左侧『扩展』页\n\n全局安装 eslint：\n\n```sh\nnpm i eslint -g\n```\n\n运行：\n\n```sh\neslint --init\n```\n\n初始化 eslint 配置，依次选择：\n\n-> Use a popular style guide  \n-> Standard  \n-> JSON\n\n> 注意：如果 Windows 用户使用其他命令行工具无法上下切换选项，切换回 cmd。\n\neslint 会创建一个 .eslintrc.json 的配置文件，同时自动安装并添加相关的模块到 devDependencies。这里我们使用 Standard 规范，其主要特点是不加分号。\n\n### 4.2.4 EditorConfig\n\nEditorConfig 是一个保持缩进风格的一致的工具，当多人共同开发一个项目的时候，往往会出现每个人用不同编辑器的情况，而且有的人用 tab 缩进，有的人用 2 个空格缩进，有的人用 4 个空格缩进，EditorConfig 就是为了解决这个问题而诞生。\n\nEditorConfig 需要结合编辑器或 IDE 使用，如：\n\n- Sublime Text 需要装一个插件：EditorConfig\n- VS Code 需要装一个插件：EditorConfig for VS Code\n\n在 myblog 目录下新建 .editorconfig 的文件，添加如下内容：\n\n```\n# editorconfig.org\nroot = true\n\n[*]\nindent_style = space\nindent_size = 2\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newline = true\ntab_width = 2\n\n[*.md]\ntrim_trailing_whitespace = false\n\n[Makefile]\nindent_style = tab\n```\n\n这里我们使用 2 个空格缩进，tab 长度也是 2 个空格。trim_trailing_whitespace 用来删除每一行最后多余的空格，insert_final_newline 用来在代码最后插入一个空的换行。\n\n上一节：[4.1 开发环境](https://github.com/nswbmw/N-blog/blob/master/book/4.1%20%E5%BC%80%E5%8F%91%E7%8E%AF%E5%A2%83.md)\n\n下一节：[4.3 配置文件](https://github.com/nswbmw/N-blog/blob/master/book/4.3%20%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6.md)\n"
  },
  {
    "path": "book/4.3 配置文件.md",
    "content": "不管是小项目还是大项目，将配置与代码分离是一个非常好的做法。我们通常将配置写到一个配置文件里，如 config.js 或 config.json ，并放到项目的根目录下。但实际开发时我们会有许多环境，如本地开发环境、测试环境和线上环境等，不同环境的配置不同（如：MongoDB 的地址），我们不可能每次部署时都要去修改引用 config.test.js 或者 config.production.js。config-lite 模块正是你需要的。\n\n## 4.3.1 config-lite\n\n[config-lite](https://www.npmjs.com/package/config-lite) 是一个轻量的读取配置文件的模块。config-lite 会根据环境变量（`NODE_ENV`）的不同加载 config 目录下不同的配置文件。如果不设置 `NODE_ENV`，则读取默认的 default 配置文件，如果设置了 `NODE_ENV`，则会合并指定的配置文件和 default 配置文件作为配置，config-lite 支持 .js、.json、.node、.yml、.yaml 后缀的文件。\n\n如果程序以 `NODE_ENV=test node app` 启动，则 config-lite 会依次降级查找 `config/test.js`、`config/test.json`、`config/test.node`、`config/test.yml`、`config/test.yaml` 并合并 default 配置; 如果程序以 `NODE_ENV=production node app` 启动，则 config-lite 会依次降级查找 `config/production.js`、`config/production.json`、`config/production.node`、`config/production.yml`、`config/production.yaml` 并合并 default 配置。\n\nconfig-lite 还支持冒泡查找配置，即从传入的路径开始，从该目录不断往上一级目录查找 config 目录，直到找到或者到达根目录为止。\n\n在 myblog 下新建 config 目录，在该目录下新建 default.js，添加如下代码：\n\n**config/default.js**\n\n```js\nmodule.exports = {\n  port: 3000,\n  session: {\n    secret: 'myblog',\n    key: 'myblog',\n    maxAge: 2592000000\n  },\n  mongodb: 'mongodb://localhost:27017/myblog'\n}\n```\n\n配置释义：\n\n1. `port`: 程序启动要监听的端口号\n2. `session`: express-session 的配置信息，后面介绍\n3. `mongodb`: mongodb 的地址，以 `mongodb://` 协议开头，`myblog` 为 db 名\n\n上一节：[4.2 准备工作](https://github.com/nswbmw/N-blog/blob/master/book/4.2%20%E5%87%86%E5%A4%87%E5%B7%A5%E4%BD%9C.md)\n\n下一节：[4.4 功能设计](https://github.com/nswbmw/N-blog/blob/master/book/4.4%20%E5%8A%9F%E8%83%BD%E8%AE%BE%E8%AE%A1.md)"
  },
  {
    "path": "book/4.4 功能设计.md",
    "content": "## 4.4.1 功能与路由设计\n\n在开发博客之前，我们首先需要明确博客要实现哪些功能。由于本教程面向初学者，所以只实现了博客最基本的功能，其余的功能（如归档、标签、分页等等）读者可自行实现。\n\n功能及路由设计如下：\n\n1. 注册\n    1. 注册页：`GET /signup`\n    2. 注册（包含上传头像）：`POST /signup`\n2. 登录\n    1. 登录页：`GET /signin`\n    2. 登录：`POST /signin`\n3. 登出：`GET /signout`\n4. 查看文章\n    1. 主页：`GET /posts`\n    2. 个人主页：`GET /posts?author=xxx`\n    3. 查看一篇文章（包含留言）：`GET /posts/:postId`\n5. 发表文章\n    1. 发表文章页：`GET /posts/create`\n    2. 发表文章：`POST /posts/create`\n6. 修改文章\n    1. 修改文章页：`GET /posts/:postId/edit`\n    2. 修改文章：`POST /posts/:postId/edit`\n7. 删除文章：`GET /posts/:postId/remove`\n8. 留言\n    1. 创建留言：`POST /comments`\n    2. 删除留言：`GET /comments/:commentId/remove`\n\n由于我们博客页面是后端渲染的，所以只通过简单的 `<a>(GET)` 和 `<form>(POST)` 与后端进行交互，如果使用 jQuery 或者其他前端框架（如 Angular、Vue、React 等等）可通过 Ajax 与后端交互，则 api 的设计应尽量遵循 Restful 风格。\n\n#### Restful\n\nRestful 是一种 api 的设计风格，提出了一组 api 的设计原则和约束条件。\n\n如上面删除文章的路由设计：\n\n```\nGET /posts/:postId/remove\n```\n\nRestful 风格的设计：\n\n```\nDELETE /posts/:postId\n```\n\n可以看出，Restful 风格的 api 更直观且优雅。\n\n更多阅读：\n\n1. http://www.ruanyifeng.com/blog/2011/09/restful\n2. http://www.ruanyifeng.com/blog/2014/05/restful_api.html\n3. http://developer.51cto.com/art/200908/141825.htm\n4. http://blog.jobbole.com/41233/\n\n## 4.4.2 会话\n\n由于 HTTP 协议是无状态的协议，所以服务端需要记录用户的状态时，就需要用某种机制来识别具体的用户，这个机制就是会话（Session）。\n\n#### cookie 与 session 的区别\n\n1. cookie 存储在浏览器（有大小限制），session 存储在服务端（没有大小限制）\n2. 通常 session 的实现是基于 cookie 的，session id 存储于 cookie 中\n3. session 更安全，cookie 可以直接在浏览器查看甚至编辑\n\n更多 session 的资料，参考：https://www.zhihu.com/question/19786827\n\n我们通过引入 express-session 中间件实现对会话的支持：\n\n```js\napp.use(session(options))\n```\n\nsession 中间件会在 req 上添加 session 对象，即 req.session 初始值为 `{}`，当我们登录后设置 `req.session.user = 用户信息`，返回浏览器的头信息中会带上 `set-cookie` 将 session id 写到浏览器 cookie 中，那么该用户下次请求时，通过带上来的 cookie 中的 session id 我们就可以查找到该用户，并将用户信息保存到 `req.session.user`。\n\n## 4.4.3 页面通知\n\n我们还需要这样一个功能：当我们操作成功时需要显示一个成功的通知，如登录成功跳转到主页时，需要显示一个 `登陆成功` 的通知；当我们操作失败时需要显示一个失败的通知，如注册时用户名被占用了，需要显示一个 `用户名已占用` 的通知。通知只显示一次，刷新后消失，我们可以通过 connect-flash 中间件实现这个功能。\n\n[connect-flash](https://www.npmjs.com/package/connect-flash) 是基于 session 实现的，它的原理很简单：设置初始值 `req.session.flash={}`，通过 `req.flash(name, value)` 设置这个对象下的字段和值，通过 `req.flash(name)` 获取这个对象下的值，同时删除这个字段，实现了只显示一次刷新后消失的功能。\n\n#### express-session、connect-mongo 和 connect-flash 的区别与联系\n\n1. `express-session`: 会话（session）支持中间件\n2. `connect-mongo`: 将 session 存储于 mongodb，需结合 express-session 使用，我们也可以将 session 存储于 redis，如 [connect-redis](https://www.npmjs.com/package/connect-redis)\n3. `connect-flash`: 基于 session 实现的用于通知功能的中间件，需结合 express-session 使用\n\n## 4.4.4 权限控制\n\n不管是论坛还是博客网站，我们没有登录的话只能浏览，登陆后才能发帖或写文章，即使登录了你也不能修改或删除其他人的文章，这就是权限控制。我们也来给博客添加权限控制，如何实现页面的权限控制呢？我们可以把用户状态的检查封装成一个中间件，在每个需要权限控制的路由加载该中间件，即可实现页面的权限控制。在 myblog 下新建 middlewares 目录，在该目录下新建 check.js，添加如下代码：\n\n**middlewares/check.js**\n\n```js\nmodule.exports = {\n  checkLogin: function checkLogin (req, res, next) {\n    if (!req.session.user) {\n      req.flash('error', '未登录')\n      return res.redirect('/signin')\n    }\n    next()\n  },\n\n  checkNotLogin: function checkNotLogin (req, res, next) {\n    if (req.session.user) {\n      req.flash('error', '已登录')\n      return res.redirect('back')// 返回之前的页面\n    }\n    next()\n  }\n}\n```\n\n可以看出：\n\n1. `checkLogin`: 当用户信息（`req.session.user`）不存在，即认为用户没有登录，则跳转到登录页，同时显示 `未登录` 的通知，用于需要用户登录才能操作的页面\n2. `checkNotLogin`: 当用户信息（`req.session.user`）存在，即认为用户已经登录，则跳转到之前的页面，同时显示 `已登录` 的通知，如已登录用户就禁止访问登录、注册页面\n\n最终我们创建以下路由文件：\n\n**routes/index.js**\n\n```js\nmodule.exports = function (app) {\n  app.get('/', function (req, res) {\n    res.redirect('/posts')\n  })\n  app.use('/signup', require('./signup'))\n  app.use('/signin', require('./signin'))\n  app.use('/signout', require('./signout'))\n  app.use('/posts', require('./posts'))\n  app.use('/comments', require('./comments'))\n}\n```\n\n**routes/posts.js**\n\n```js\nconst express = require('express')\nconst router = express.Router()\n\nconst checkLogin = require('../middlewares/check').checkLogin\n\n// GET /posts 所有用户或者特定用户的文章页\n//   eg: GET /posts?author=xxx\nrouter.get('/', function (req, res, next) {\n  res.send('主页')\n})\n\n// POST /posts/create 发表一篇文章\nrouter.post('/create', checkLogin, function (req, res, next) {\n  res.send('发表文章')\n})\n\n// GET /posts/create 发表文章页\nrouter.get('/create', checkLogin, function (req, res, next) {\n  res.send('发表文章页')\n})\n\n// GET /posts/:postId 单独一篇的文章页\nrouter.get('/:postId', function (req, res, next) {\n  res.send('文章详情页')\n})\n\n// GET /posts/:postId/edit 更新文章页\nrouter.get('/:postId/edit', checkLogin, function (req, res, next) {\n  res.send('更新文章页')\n})\n\n// POST /posts/:postId/edit 更新一篇文章\nrouter.post('/:postId/edit', checkLogin, function (req, res, next) {\n  res.send('更新文章')\n})\n\n// GET /posts/:postId/remove 删除一篇文章\nrouter.get('/:postId/remove', checkLogin, function (req, res, next) {\n  res.send('删除文章')\n})\n\nmodule.exports = router\n```\n\n**routes/comments.js**\n\n```js\nconst express = require('express')\nconst router = express.Router()\nconst checkLogin = require('../middlewares/check').checkLogin\n\n// POST /comments 创建一条留言\nrouter.post('/', checkLogin, function (req, res, next) {\n  res.send('创建留言')\n})\n\n// GET /comments/:commentId/remove 删除一条留言\nrouter.get('/:commentId/remove', checkLogin, function (req, res, next) {\n  res.send('删除留言')\n})\n\nmodule.exports = router\n```\n\n**routes/signin.js**\n\n```js\nconst express = require('express')\nconst router = express.Router()\n\nconst checkNotLogin = require('../middlewares/check').checkNotLogin\n\n// GET /signin 登录页\nrouter.get('/', checkNotLogin, function (req, res, next) {\n  res.send('登录页')\n})\n\n// POST /signin 用户登录\nrouter.post('/', checkNotLogin, function (req, res, next) {\n  res.send('登录')\n})\n\nmodule.exports = router\n```\n\n**routes/signup.js**\n\n```js\nconst express = require('express')\nconst router = express.Router()\n\nconst checkNotLogin = require('../middlewares/check').checkNotLogin\n\n// GET /signup 注册页\nrouter.get('/', checkNotLogin, function (req, res, next) {\n  res.send('注册页')\n})\n\n// POST /signup 用户注册\nrouter.post('/', checkNotLogin, function (req, res, next) {\n  res.send('注册')\n})\n\nmodule.exports = router\n```\n\n**routes/signout.js**\n\n```js\nconst express = require('express')\nconst router = express.Router()\n\nconst checkLogin = require('../middlewares/check').checkLogin\n\n// GET /signout 登出\nrouter.get('/', checkLogin, function (req, res, next) {\n  res.send('登出')\n})\n\nmodule.exports = router\n```\n\n最后，修改 index.js 如下：\n\n**index.js**\n\n```js\nconst path = require('path')\nconst express = require('express')\nconst session = require('express-session')\nconst MongoStore = require('connect-mongo')(session)\nconst flash = require('connect-flash')\nconst config = require('config-lite')(__dirname)\nconst routes = require('./routes')\nconst pkg = require('./package')\n\nconst app = express()\n\n// 设置模板目录\napp.set('views', path.join(__dirname, 'views'))\n// 设置模板引擎为 ejs\napp.set('view engine', 'ejs')\n\n// 设置静态文件目录\napp.use(express.static(path.join(__dirname, 'public')))\n// session 中间件\napp.use(session({\n  name: config.session.key, // 设置 cookie 中保存 session id 的字段名称\n  secret: config.session.secret, // 通过设置 secret 来计算 hash 值并放在 cookie 中，使产生的 signedCookie 防篡改\n  resave: true, // 强制更新 session\n  saveUninitialized: false, // 设置为 false，强制创建一个 session，即使用户未登录\n  cookie: {\n    maxAge: config.session.maxAge// 过期时间，过期后 cookie 中的 session id 自动删除\n  },\n  store: new MongoStore({// 将 session 存储到 mongodb\n    url: config.mongodb// mongodb 地址\n  })\n}))\n// flash 中间件，用来显示通知\napp.use(flash())\n\n// 路由\nroutes(app)\n\n// 监听端口，启动程序\napp.listen(config.port, function () {\n  console.log(`${pkg.name} listening on port ${config.port}`)\n})\n```\n\n> 注意：中间件的加载顺序很重要。如上面设置静态文件目录的中间件应该放到 routes(app) 之前加载，这样静态文件的请求就不会落到业务逻辑的路由里；flash 中间件应该放到 session 中间件之后加载，因为 flash 是基于 session 实现的。\n\n运行 `supervisor index` 启动博客，访问以下地址查看效果：\n\n1. http://localhost:3000/posts\n2. http://localhost:3000/signout\n3. http://localhost:3000/signup\n\n上一节：[4.3 配置文件](https://github.com/nswbmw/N-blog/blob/master/book/4.3%20%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6.md)\n\n下一节：[4.5 页面设计](https://github.com/nswbmw/N-blog/blob/master/book/4.5%20%E9%A1%B5%E9%9D%A2%E8%AE%BE%E8%AE%A1.md)\n"
  },
  {
    "path": "book/4.5 页面设计.md",
    "content": "我们使用 jQuery + Semantic-UI 实现前端页面的设计，最终效果图如下:\n\n**注册页**\n\n![](./img/4.5.1.png)\n\n**登录页**\n\n![](./img/4.5.2.png)\n\n**未登录时的主页（或用户页）**\n\n![](./img/4.5.3.png)\n\n**登录后的主页（或用户页）**\n\n![](./img/4.5.4.png)\n\n**发表文章页**\n\n![](./img/4.5.5.png)\n\n**编辑文章页**\n\n![](./img/4.5.6.png)\n\n**未登录时的文章页**\n\n![](./img/4.5.7.png)\n\n**登录后的文章页**\n\n![](./img/4.5.8.png)\n\n**通知**\n\n![](./img/4.5.9.png)\n![](./img/4.5.10.png)\n![](./img/4.5.11.png)\n\n## 4.5.1 组件\n\n前面提到过，我们可以将模板拆分成一些组件，然后使用 ejs 的 include 方法将组件组合起来进行渲染。我们将页面切分成以下组件：\n\n**主页**\n\n![](./img/4.5.12.png)\n\n**文章页**\n\n![](./img/4.5.13.png)\n\n根据上面的组件切分图，我们创建以下样式及模板文件：\n\n**public/css/style.css**\n\n```css\n/* ---------- 全局样式 ---------- */\n\nbody {\n  width: 1100px;\n  height: 100%;\n  margin: 0 auto;\n  padding-top: 40px;\n}\n\na:hover {\n  border-bottom: 3px solid #4fc08d;\n}\n\n.button {\n  background-color: #4fc08d !important;\n  color: #fff !important;\n}\n\n.avatar {\n  border-radius: 3px;\n  width: 48px;\n  height: 48px;\n  float: right;\n}\n\n/* ---------- nav ---------- */\n\n.nav {\n  margin-bottom: 20px;\n  color: #999;\n  text-align: center;\n}\n\n.nav h1 {\n  color: #4fc08d;\n  display: inline-block;\n  margin: 10px 0;\n}\n\n/* ---------- nav-setting ---------- */\n\n.nav-setting {\n  position: fixed;\n  right: 30px;\n  top: 35px;\n  z-index: 999;\n}\n\n.nav-setting .ui.dropdown.button {\n  padding: 10px 10px 0 10px;\n  background-color: #fff !important;\n}\n\n.nav-setting .icon.bars {\n  color: #000;\n  font-size: 18px;\n}\n\n/* ---------- post-content ---------- */\n\n.post-content h3 a {\n  color: #4fc08d !important;\n}\n\n.post-content .tag {\n  font-size: 13px;\n  margin-right: 5px;\n  color: #999;\n}\n\n.post-content .tag.right {\n  float: right;\n  margin-right: 0;\n}\n\n.post-content .tag.right a {\n  color: #999;\n}\n```\n\n**views/header.ejs**\n\n```ejs\n<!DOCTYPE html>\n<html>\n  <head>\n    <meta charset=\"utf-8\">\n    <title><%= blog.title %></title>\n    <link rel=\"stylesheet\" href=\"//cdn.bootcss.com/semantic-ui/2.1.8/semantic.min.css\">\n    <link rel=\"stylesheet\" href=\"/css/style.css\">\n    <script src=\"//cdn.bootcss.com/jquery/1.11.3/jquery.min.js\"></script>\n    <script src=\"//cdn.bootcss.com/semantic-ui/2.1.8/semantic.min.js\"></script>\n  </head>\n  <body>\n  <%- include('components/nav') %>\n  <%- include('components/nav-setting') %>\n  <%- include('components/notification') %>\n```\n\n**views/footer.ejs**\n\n```ejs\n  <script type=\"text/javascript\">\n   $(document).ready(function () {\n      // 点击按钮弹出下拉框\n      $('.ui.dropdown').dropdown();\n      \n      // 鼠标悬浮在头像上，弹出气泡提示框\n      $('.post-content .avatar-link').popup({\n        inline: true,\n        position: 'bottom right',\n        lastResort: 'bottom right'\n      });\n    })\n  </script>\n  </body>\n</html>\n```\n\n> 注意：上面 `<script></script>` 是 semantic-ui 操控页面控件的代码，一定要放到 footer.ejs 的 `</body>` 的前面，因为只有页面加载完后才能通过 JQuery 获取 DOM 元素。\n\n在 views 目录下新建 components 目录用来存放组件（即可以复用的模板片段），在该目录下创建以下文件：\n\n**views/components/nav.ejs**\n\n```ejs\n<div class=\"nav\">\n  <div class=\"ui grid\">\n    <div class=\"four wide column\"></div>\n\n    <div class=\"eight wide column\">\n      <a href=\"/posts\"><h1><%= blog.title %></h1></a>\n      <p><%= blog.description %></p>\n    </div>\n  </div>\n</div>\n```\n\n**views/components/nav-setting.ejs**\n\n```ejs\n<div class=\"nav-setting\">\n  <div class=\"ui buttons\">\n    <div class=\"ui floating dropdown button\">\n      <i class=\"icon bars\"></i>\n      <div class=\"menu\">\n        <% if (user) { %>\n          <a class=\"item\" href=\"/posts?author=<%= user._id %>\">个人主页</a>\n          <div class=\"divider\"></div>\n          <a class=\"item\" href=\"/posts/create\">发表文章</a>\n          <a class=\"item\" href=\"/signout\">登出</a>\n        <% } else { %>\n          <a class=\"item\" href=\"/signin\">登录</a>\n          <a class=\"item\" href=\"/signup\">注册</a>\n        <% } %>\n      </div>\n    </div>\n  </div>\n</div>\n```\n\n**views/components/notification.ejs**\n\n```ejs\n<div class=\"ui grid\">\n  <div class=\"four wide column\"></div>\n  <div class=\"eight wide column\">\n\n  <% if (success) { %>\n    <div class=\"ui success message\">\n      <p><%= success %></p>\n    </div>\n  <% } %>\n\n  <% if (error) { %>\n    <div class=\"ui error message\">\n      <p><%= error %></p>\n    </div>\n  <% } %>\n\n  </div>\n</div>\n```\n\n## 4.5.2 app.locals 和 res.locals\n\n上面的 ejs 模板中我们用到了 blog、user、success、error 变量，我们将 blog 变量挂载到 `app.locals` 下，将 user、success、error 挂载到 `res.locals` 下。为什么要这么做呢？`app.locals` 和 `res.locals` 是什么？它们有什么区别？\n\nexpress 中有两个对象可用于模板的渲染：`app.locals` 和 `res.locals`。我们从 express 源码一探究竟：\n\n**express/lib/application.js**\n\n```js\napp.render = function render(name, options, callback) {\n  ...\n  var opts = options;\n  var renderOptions = {};\n  ...\n  // merge app.locals\n  merge(renderOptions, this.locals);\n\n  // merge options._locals\n  if (opts._locals) {\n    merge(renderOptions, opts._locals);\n  }\n\n  // merge options\n  merge(renderOptions, opts);\n  ...\n  tryRender(view, renderOptions, done);\n};\n```\n\n**express/lib/response.js**\n\n```js\nres.render = function render(view, options, callback) {\n  var app = this.req.app;\n  var opts = options || {};\n  ...\n  // merge res.locals\n  opts._locals = self.locals;\n  ...\n  // render\n  app.render(view, opts, done);\n};\n```\n\n可以看出：在调用 `res.render` 的时候，express 合并（merge）了 3 处的结果后传入要渲染的模板，优先级：`res.render` 传入的对象> `res.locals` 对象 > `app.locals` 对象，所以 `app.locals` 和 `res.locals` 几乎没有区别，都用来渲染模板，使用上的区别在于：`app.locals` 上通常挂载常量信息（如博客名、描述、作者这种不会变的信息），`res.locals` 上通常挂载变量信息，即每次请求可能的值都不一样（如请求者信息，`res.locals.user = req.session.user`）。\n\n修改 index.js，在 `routes(app)` 上一行添加如下代码：\n\n```js\n// 设置模板全局常量\napp.locals.blog = {\n  title: pkg.name,\n  description: pkg.description\n}\n\n// 添加模板必需的三个变量\napp.use(function (req, res, next) {\n  res.locals.user = req.session.user\n  res.locals.success = req.flash('success').toString()\n  res.locals.error = req.flash('error').toString()\n  next()\n})\n```\n\n这样在调用 `res.render` 的时候就不用传入这四个变量了，express 为我们自动 merge 并传入了模板，所以我们可以在模板中直接使用这四个变量。\n\n上一节：[4.4 功能设计](https://github.com/nswbmw/N-blog/blob/master/book/4.4%20%E5%8A%9F%E8%83%BD%E8%AE%BE%E8%AE%A1.md)\n\n下一节：[4.6 连接数据库](https://github.com/nswbmw/N-blog/blob/master/book/4.6%20%E8%BF%9E%E6%8E%A5%E6%95%B0%E6%8D%AE%E5%BA%93.md)\n"
  },
  {
    "path": "book/4.6 连接数据库.md",
    "content": "我们使用 [Mongolass](https://github.com/mongolass/mongolass) 这个模块操作 mongodb 进行增删改查。在 myblog 下新建 lib 目录，在该目录下新建 mongo.js，添加如下代码：\n\n**lib/mongo.js**\n\n```js\nconst config = require('config-lite')(__dirname)\nconst Mongolass = require('mongolass')\nconst mongolass = new Mongolass()\nmongolass.connect(config.mongodb)\n```\n\n## 4.6.1 为什么使用 Mongolass\n\n早期我使用官方的 [mongodb](https://www.npmjs.com/package/mongodb)（也叫 node-mongodb-native）库，后来也陆续尝试使用了许多其他 mongodb 的驱动库，[Mongoose](https://www.npmjs.com/package/mongoose) 是比较优秀的一个，使用 Mongoose 的时间也比较长。比较这两者，各有优缺点。\n\n#### node-mongodb-native:\n\n**优点：**\n\n1. 简单。参照文档即可上手，没有 Mongoose 的 Schema 那些对新手不友好的东西。\n2. 强大。毕竟是官方库，包含了所有且最新的 api，其他大部分的库都是在这个库的基础上改造的，包括 Mongoose。\n3. 文档健全。\n\n**缺点：**\n\n1. 起初只支持 callback，会写出以下这种代码：\n```js\nmongodb.open(function (err, db) {\n  if (err) {\n    return callback(err)\n  }\n  db.collection('users', function (err, collection) {\n    if (err) {\n      return callback(err)\n    }\n    collection.find({ name: 'xxx' }, function (err, users) {\n      if (err) {\n        return callback(err)\n      }\n    })\n  ...\n```\n\n或者：\n\n```js\nMongoClient.connect('mongodb://localhost:27017', function (err, mongodb) {\n  if (err) {\n    return callback(err)\n  }\n  mongodb.db('test').collection('users').find({ name: 'xxx' }, function (err, users) {\n    if (err) {\n      return callback(err)\n    }\n  })\n  ...\n```\n\n现在支持 Promise 了，和 co 一起使用好很多。\n\n2. 不支持文档校验。Mongoose 通过 Schema 支持文档校验，虽说 mongodb 是 no schema 的，但在生产环境中使用 Schema 有两点好处。一是对文档做校验，防止非正常情况下写入错误的数据到数据库，二是可以简化一些代码，如类型为 ObjectId 的字段查询或更新时可通过对应的字符串操作，不用每次包装成 ObjectId 对象。\n\n#### Mongoose:\n\n**优点：**\n\n1. 封装了数据库的操作，给人的感觉是同步的，其实内部是异步的。如 mongoose 与 MongoDB 建立连接：\n```js\nconst mongoose = require('mongoose')\nmongoose.connect('mongodb://localhost/test')\nconst BlogModel = mongoose.model('Blog', { title: String, content: String })\nBlogModel.find()\n```\n2. 支持 Promise。这个也无需多说，Promise 是未来趋势，可结合 co 使用，也可结合 async/await 使用。\n3. 支持文档校验。如上所述。\n\n**缺点（个人观点）：**\n\n1. 功能多，复杂。Mongoose 功能很强大，包括静态方法，实例方法，虚拟属性，hook 函数等等，混用带来的后果是逻辑复杂，代码难以维护。\n2. 较弱的 plugin 系统。如：`schema.pre('save', function(next) {})` 和 `schema.post('find', function(next) {})`，只支持异步 `next()`，灵活性大打折扣。\n3. 其他：对新手来说难以理解的 Schema、Model、Entity 之间的关系；容易混淆的 toJSON 和 toObject，以及有带有虚拟属性的情况；用和不用 exec 的情况以及直接用 then 的情况；返回的结果是 Mongoose 包装后的对象，在此对象上修改结果却无效等等。\n\n#### Mongolass\n\nMongolass 保持了与 mongodb 一样的 api，又借鉴了许多 Mongoose 的优点，同时又保持了精简。\n\n**优点：**\n\n1. 支持 Promise。\n2. 官方一致的 api。\n2. 简单。参考 Mongolass 的 readme 即可上手，比 Mongoose 精简的多，本身代码也不多。\n3. 可选的 Schema。Mongolass 中的 Schema （基于 [another-json-schema](https://www.npmjs.com/package/another-json-schema)）是可选的，并且只用来做文档校验。如果定义了 schema 并关联到某个 model，则插入、更新和覆盖等操作都会校验文档是否满足 schema，同时 schema 也会尝试格式化该字段，类似于 Mongoose，如定义了一个字段为 ObjectId 类型，也可以用 ObjectId 的字符串无缝使用一样。如果没有 schema，则用法跟原生 mongodb 库一样。\n4. 简单却强大的插件系统。可以定义全局插件（对所有 model 生效），也可以定义某个 model 上的插件（只对该 model 生效）。Mongolass 插件的设计思路借鉴了中间件的概念（类似于 Koa），通过定义 `beforeXXX` 和 `afterXXX` （XXX为操作符首字母大写，如：`afterFind`）函数实现，函数返回 yieldable 的对象即可，所以每个插件内可以做一些其他的 IO 操作。不同的插件顺序会有不同的结果，而且每个插件的输入输出都是 plain object，而非类 Mongoose 包装后的对象，没有虚拟属性，无需调用 toJSON 或 toObject。Mongolass 中的 `.populate()`就是一个内置的插件。\n5. 详细的错误信息。用过 Mongoose 的人一定遇到过这样的错：\n   `CastError: Cast to ObjectId failed for value \"xxx\" at path \"_id\"`\n   只知道一个期望是 ObjectId 的字段传入了非期望的值，通常很难定位出错的代码，即使定位到也得不到错误现场。得益于 [another-json-schema](https://www.npmjs.com/package/another-json-schema)，使用 Mongolass 在查询或者更新时，某个字段不匹配它定义的 schema 时（还没落到 mongodb）会给出详细的错误信息，如下所示：\n```js\nconst Mongolass = require('mongolass')\nconst mongolass = new Mongolass('mongodb://localhost:27017/test')\n\nconst User = mongolass.model('User', {\n  name: { type: 'string' },\n  age: { type: 'number' }\n})\n\nUser\n  .insertOne({ name: 'nswbmw', age: 'wrong age' })\n  .exec()\n  .then(console.log)\n  .catch(function (e) {\n    console.error(e)\n    console.error(e.stack)\n  })\n/*\n{ [Error: ($.age: \"wrong age\") ✖ (type: number)]\n  validator: 'type',\n  actual: 'wrong age',\n  expected: { type: 'number' },\n  path: '$.age',\n  schema: 'UserSchema',\n  model: 'User',\n  plugin: 'MongolassSchema',\n  type: 'beforeInsertOne',\n  args: [] }\nError: ($.age: \"wrong age\") ✖ (type: number)\n    at Model.insertOne (/Users/nswbmw/Desktop/mongolass-demo/node_modules/mongolass/lib/query.js:108:16)\n    at Object.<anonymous> (/Users/nswbmw/Desktop/mongolass-demo/app.js:10:4)\n    at Module._compile (module.js:409:26)\n    at Object.Module._extensions..js (module.js:416:10)\n    at Module.load (module.js:343:32)\n    at Function.Module._load (module.js:300:12)\n    at Function.Module.runMain (module.js:441:10)\n    at startup (node.js:139:18)\n    at node.js:974:3\n */\n```\n可以看出，错误的原因是在 insertOne 一条用户数据到用户表的时候，age 期望是一个 number 类型的值，而我们传入的字符串 `wrong age`，然后从错误栈中可以快速定位到是 app.js 第 10 行代码抛出的错。\n\n**缺点：**\n\n1. ~~schema 功能较弱，缺少如 required、default 功能。~~\n\n### 扩展阅读\n\n[从零开始写一个 Node.js 的 MongoDB 驱动库](https://zhuanlan.zhihu.com/p/24308524)\n\n上一节：[4.5 页面设计](https://github.com/nswbmw/N-blog/blob/master/book/4.5%20%E9%A1%B5%E9%9D%A2%E8%AE%BE%E8%AE%A1.md)\n\n下一节：[4.7 注册](https://github.com/nswbmw/N-blog/blob/master/book/4.7%20%E6%B3%A8%E5%86%8C.md)\n"
  },
  {
    "path": "book/4.7 注册.md",
    "content": "## 4.7.1 用户模型设计\n\n我们只存储用户的名称、密码（加密后的）、头像、性别和个人简介这几个字段，对应修改 lib/mongo.js，添加如下代码：\n\n**lib/mongo.js**\n\n```js\nexports.User = mongolass.model('User', {\n  name: { type: 'string', required: true },\n  password: { type: 'string', required: true },\n  avatar: { type: 'string', required: true },\n  gender: { type: 'string', enum: ['m', 'f', 'x'], default: 'x' },\n  bio: { type: 'string', required: true }\n})\nexports.User.index({ name: 1 }, { unique: true }).exec()// 根据用户名找到用户，用户名全局唯一\n```\n\n我们定义了用户表的 schema，生成并导出了 User 这个 model，同时设置了 name 的唯一索引，保证用户名是不重复的。\n\n> 小提示：`required: true` 表示该字段是必需的，`default: xxx` 用于创建文档时设置默认值。更多关于 Mongolass 的 schema 的用法，请查阅 [another-json-schema](https://github.com/nswbmw/another-json-schema)。\n\n> 小提示：Mongolass 中的 model 你可以认为相当于 mongodb 中的 collection，只不过添加了插件的功能。\n\n## 4.7.2 注册页\n\n首先，我们来完成注册。新建 views/signup.ejs，添加如下代码：\n\n**views/signup.ejs**\n\n```ejs\n<%- include('header') %>\n\n<div class=\"ui grid\">\n  <div class=\"four wide column\"></div>\n  <div class=\"eight wide column\">\n    <form class=\"ui form segment\" method=\"post\" enctype=\"multipart/form-data\">\n      <div class=\"field required\">\n        <label>用户名</label>\n        <input placeholder=\"用户名\" type=\"text\" name=\"name\">\n      </div>\n      <div class=\"field required\">\n        <label>密码</label>\n        <input placeholder=\"密码\" type=\"password\" name=\"password\">\n      </div>\n      <div class=\"field required\">\n        <label>重复密码</label>\n        <input placeholder=\"重复密码\" type=\"password\" name=\"repassword\">\n      </div>\n      <div class=\"field required\">\n        <label>性别</label>\n        <select class=\"ui compact selection dropdown\" name=\"gender\">\n          <option value=\"m\">男</option>\n          <option value=\"f\">女</option>\n          <option value=\"x\">保密</option>\n        </select>\n      </div>\n      <div class=\"field required\">\n        <label>头像</label>\n        <input type=\"file\" name=\"avatar\">\n      </div>\n      <div class=\"field required\">\n        <label>个人简介</label>\n        <textarea name=\"bio\" rows=\"5\"></textarea>\n      </div>\n      <input type=\"submit\" class=\"ui button fluid\" value=\"注册\">\n    </form>\n  </div>\n</div>\n\n<%- include('footer') %>\n```\n\n> 注意：form 表单要添加 `enctype=\"multipart/form-data\"` 属性才能上传文件。\n\n修改 routes/signup.js 中获取注册页的路由如下：\n\n**routes/signup.js**\n\n```js\n// GET /signup 注册页\nrouter.get('/', checkNotLogin, function (req, res, next) {\n  res.render('signup')\n})\n```\n\n现在访问 `localhost:3000/signup` 看看效果吧。\n\n## 4.7.3 注册与文件上传\n\n我们使用 [express-formidable](https://github.com/utatti/express-formidable) 处理 form 表单（包括文件上传）。修改 index.js ，在 `app.use(flash())` 下一行添加如下代码：\n\n**index.js**\n\n```js\n// 处理表单及文件上传的中间件\napp.use(require('express-formidable')({\n  uploadDir: path.join(__dirname, 'public/img'), // 上传文件目录\n  keepExtensions: true// 保留后缀\n}))\n```\n\n新建 models/users.js，添加如下代码：\n\n**models/users.js**\n\n```js\nconst User = require('../lib/mongo').User\n\nmodule.exports = {\n  // 注册一个用户\n  create: function create (user) {\n    return User.create(user).exec()\n  }\n}\n```\n\n完善处理用户注册的路由，最终修改 routes/signup.js 如下：\n\n**routes/signup.js**\n\n```js\nconst fs = require('fs')\nconst path = require('path')\nconst sha1 = require('sha1')\nconst express = require('express')\nconst router = express.Router()\n\nconst UserModel = require('../models/users')\nconst checkNotLogin = require('../middlewares/check').checkNotLogin\n\n// GET /signup 注册页\nrouter.get('/', checkNotLogin, function (req, res, next) {\n  res.render('signup')\n})\n\n// POST /signup 用户注册\nrouter.post('/', checkNotLogin, function (req, res, next) {\n  const name = req.fields.name\n  const gender = req.fields.gender\n  const bio = req.fields.bio\n  const avatar = req.files.avatar.path.split(path.sep).pop()\n  let password = req.fields.password\n  const repassword = req.fields.repassword\n\n  // 校验参数\n  try {\n    if (!(name.length >= 1 && name.length <= 10)) {\n      throw new Error('名字请限制在 1-10 个字符')\n    }\n    if (['m', 'f', 'x'].indexOf(gender) === -1) {\n      throw new Error('性别只能是 m、f 或 x')\n    }\n    if (!(bio.length >= 1 && bio.length <= 30)) {\n      throw new Error('个人简介请限制在 1-30 个字符')\n    }\n    if (!req.files.avatar.name) {\n      throw new Error('缺少头像')\n    }\n    if (password.length < 6) {\n      throw new Error('密码至少 6 个字符')\n    }\n    if (password !== repassword) {\n      throw new Error('两次输入密码不一致')\n    }\n  } catch (e) {\n    // 注册失败，异步删除上传的头像\n    fs.unlink(req.files.avatar.path)\n    req.flash('error', e.message)\n    return res.redirect('/signup')\n  }\n\n  // 明文密码加密\n  password = sha1(password)\n\n  // 待写入数据库的用户信息\n  let user = {\n    name: name,\n    password: password,\n    gender: gender,\n    bio: bio,\n    avatar: avatar\n  }\n  // 用户信息写入数据库\n  UserModel.create(user)\n    .then(function (result) {\n      // 此 user 是插入 mongodb 后的值，包含 _id\n      user = result.ops[0]\n      // 删除密码这种敏感信息，将用户信息存入 session\n      delete user.password\n      req.session.user = user\n      // 写入 flash\n      req.flash('success', '注册成功')\n      // 跳转到首页\n      res.redirect('/posts')\n    })\n    .catch(function (e) {\n      // 注册失败，异步删除上传的头像\n      fs.unlink(req.files.avatar.path)\n      // 用户名被占用则跳回注册页，而不是错误页\n      if (e.message.match('duplicate key')) {\n        req.flash('error', '用户名已被占用')\n        return res.redirect('/signup')\n      }\n      next(e)\n    })\n})\n\nmodule.exports = router\n```\n\n我们使用 express-formidable 处理表单的上传，表单普通字段挂载到 req.fields 上，表单上传后的文件挂载到 req.files 上，文件存储在 public/img 目录下。然后校验了参数，校验通过后将用户信息插入到 MongoDB 中，成功则跳转到主页并显示『注册成功』的通知，失败（如用户名被占用）则跳转回注册页面并显示『用户名已被占用』的通知。\n\n> 注意：我们使用 sha1 加密用户的密码，sha1 并不是一种十分安全的加密方式，实际开发中可以使用更安全的 [bcrypt](https://www.npmjs.com/package/bcrypt) 或 [scrypt](https://www.npmjs.com/package/scrypt) 加密。\n> 注意：注册失败时（参数校验失败或者存数据库时出错）删除已经上传到 public/img 目录下的头像。\n\n为了方便观察效果，我们先创建主页的模板。修改 routes/posts.js 中对应代码如下：\n\n**routes/posts.js**\n\n```js\nrouter.get('/', function (req, res, next) {\n  res.render('posts')\n})\n```\n\n新建 views/posts.ejs，添加如下代码：\n\n**views/posts.ejs**\n\n```ejs\n<%- include('header') %>\n这是主页\n<%- include('footer') %>\n```\n\n访问 `localhost:3000/signup`，注册成功后如下所示：\n\n![](./img/4.7.1.png)\n\n上一节：[4.6 连接数据库](https://github.com/nswbmw/N-blog/blob/master/book/4.6%20%E8%BF%9E%E6%8E%A5%E6%95%B0%E6%8D%AE%E5%BA%93.md)\n\n下一节：[4.8 登出与登录](https://github.com/nswbmw/N-blog/blob/master/book/4.8%20%E7%99%BB%E5%87%BA%E4%B8%8E%E7%99%BB%E5%BD%95.md)\n"
  },
  {
    "path": "book/4.8 登出与登录.md",
    "content": "## 4.8.1 登出\n\n现在我们来完成登出的功能。修改 routes/signout.js 如下：\n\n**routes/signout.js**\n\n```js\nconst express = require('express')\nconst router = express.Router()\n\nconst checkLogin = require('../middlewares/check').checkLogin\n\n// GET /signout 登出\nrouter.get('/', checkLogin, function (req, res, next) {\n  // 清空 session 中用户信息\n  req.session.user = null\n  req.flash('success', '登出成功')\n  // 登出成功后跳转到主页\n  res.redirect('/posts')\n})\n\nmodule.exports = router\n```\n\n此时刷新页面，点击右上角的 `登出`，成功后如下图所示：\n\n![](./img/4.8.1.png)\n\n## 4.8.2 登录页\n\n现在我们来完成登录页。修改 routes/signin.js 相应代码如下：\n\n**routes/signin.js**\n\n```js\nrouter.get('/', checkNotLogin, function (req, res, next) {\n  res.render('signin')\n})\n```\n\n新建 views/signin.ejs，添加如下代码：\n\n**views/signin.ejs**\n\n```ejs\n<%- include('header') %>\n\n<div class=\"ui grid\">\n  <div class=\"four wide column\"></div>\n  <div class=\"eight wide column\">\n    <form class=\"ui form segment\" method=\"post\">\n      <div class=\"field required\">\n        <label>用户名</label>\n        <input placeholder=\"用户名\" type=\"text\" name=\"name\">\n      </div>\n      <div class=\"field required\">\n        <label>密码</label>\n        <input placeholder=\"密码\" type=\"password\" name=\"password\">\n      </div>\n      <input type=\"submit\" class=\"ui button fluid\" value=\"登录\">\n    </form>  \n  </div>\n</div>\n\n<%- include('footer') %>\n```\n\n现在刷新页面，点击右边上角 `登录` 试试吧，我们已经看到了登录页，但先不要点击登录，接下来我们实现处理登录的逻辑。\n\n## 4.8.3 登录\n\n现在我们来完成登录的功能。修改 models/users.js 添加 `getUserByName` 方法用于通过用户名获取用户信息：\n\n**models/users.js**\n\n```js\nconst User = require('../lib/mongo').User\n\nmodule.exports = {\n  // 注册一个用户\n  create: function create (user) {\n    return User.create(user).exec()\n  },\n\n  // 通过用户名获取用户信息\n  getUserByName: function getUserByName (name) {\n    return User\n      .findOne({ name: name })\n      .addCreatedAt()\n      .exec()\n  }\n}\n```\n\n这里我们使用了 `addCreatedAt` 自定义插件（通过 \\_id 生成时间戳），修改 lib/mongo.js，添加如下代码：\n\n**lib/mongo.js**\n\n```js\nconst moment = require('moment')\nconst objectIdToTimestamp = require('objectid-to-timestamp')\n\n// 根据 id 生成创建时间 created_at\nmongolass.plugin('addCreatedAt', {\n  afterFind: function (results) {\n    results.forEach(function (item) {\n      item.created_at = moment(objectIdToTimestamp(item._id)).format('YYYY-MM-DD HH:mm')\n    })\n    return results\n  },\n  afterFindOne: function (result) {\n    if (result) {\n      result.created_at = moment(objectIdToTimestamp(result._id)).format('YYYY-MM-DD HH:mm')\n    }\n    return result\n  }\n})\n```\n\n> 小提示：24 位长的 ObjectId 前 4 个字节是精确到秒的时间戳，所以我们没有额外的存创建时间（如: createdAt）的字段。ObjectId 生成规则：\n\n![](./img/4.8.2.png)\n\n\n修改 routes/signin.js 如下：\n\n**routes/signin.js**\n\n```js\nconst sha1 = require('sha1')\nconst express = require('express')\nconst router = express.Router()\n\nconst UserModel = require('../models/users')\nconst checkNotLogin = require('../middlewares/check').checkNotLogin\n\n// GET /signin 登录页\nrouter.get('/', checkNotLogin, function (req, res, next) {\n  res.render('signin')\n})\n\n// POST /signin 用户登录\nrouter.post('/', checkNotLogin, function (req, res, next) {\n  const name = req.fields.name\n  const password = req.fields.password\n\n  // 校验参数\n  try {\n    if (!name.length) {\n      throw new Error('请填写用户名')\n    }\n    if (!password.length) {\n      throw new Error('请填写密码')\n    }\n  } catch (e) {\n    req.flash('error', e.message)\n    return res.redirect('back')\n  }\n\n  UserModel.getUserByName(name)\n    .then(function (user) {\n      if (!user) {\n        req.flash('error', '用户不存在')\n        return res.redirect('back')\n      }\n      // 检查密码是否匹配\n      if (sha1(password) !== user.password) {\n        req.flash('error', '用户名或密码错误')\n        return res.redirect('back')\n      }\n      req.flash('success', '登录成功')\n      // 用户信息写入 session\n      delete user.password\n      req.session.user = user\n      // 跳转到主页\n      res.redirect('/posts')\n    })\n    .catch(next)\n})\n\nmodule.exports = router\n```\n\n这里我们在 POST /signin 的路由处理函数中，通过传上来的 name 去数据库中找到对应用户，校验传上来的密码是否跟数据库中的一致。不一致则返回上一页（即登录页）并显示『用户名或密码错误』的通知，一致则将用户信息写入 session，跳转到主页并显示『登录成功』的通知。\n\n现在刷新页面，点击右上角 `登录`，用刚才注册的账号登录，如下图所示：\n\n![](./img/4.8.3.png)\n\n上一节：[4.7 注册](https://github.com/nswbmw/N-blog/blob/master/book/4.7%20%E6%B3%A8%E5%86%8C.md)\n\n下一节：[4.9 文章](https://github.com/nswbmw/N-blog/blob/master/book/4.9%20%E6%96%87%E7%AB%A0.md)\n"
  },
  {
    "path": "book/4.9 文章.md",
    "content": "## 4.9.1 文章模型设计\n\n我们只存储文章的作者 id、标题、正文和点击量这几个字段，对应修改 lib/mongo.js，添加如下代码：\n\n**lib/mongo.js**\n\n```js\nexports.Post = mongolass.model('Post', {\n  author: { type: Mongolass.Types.ObjectId, required: true },\n  title: { type: 'string', required: true },\n  content: { type: 'string', required: true },\n  pv: { type: 'number', default: 0 }\n})\nexports.Post.index({ author: 1, _id: -1 }).exec()// 按创建时间降序查看用户的文章列表\n```\n\n## 4.9.2 发表文章\n\n现在我们来实现发表文章的功能。首先创建发表文章页，新建 views/create.ejs，添加如下代码：\n\n**views/create.ejs**\n\n```ejs\n<%- include('header') %>\n\n<div class=\"ui grid\">\n  <div class=\"four wide column\">\n    <a class=\"avatar avatar-link\"\n       href=\"/posts?author=<%= user._id %>\"\n       data-title=\"<%= user.name %> | <%= ({m: '男', f: '女', x: '保密'})[user.gender] %>\"\n       data-content=\"<%= user.bio %>\">\n      <img class=\"avatar\" src=\"/img/<%= user.avatar %>\">\n    </a>\n  </div>\n\n  <div class=\"eight wide column\">\n    <form class=\"ui form segment\" method=\"post\">\n      <div class=\"field required\">\n        <label>标题</label>\n        <input type=\"text\" name=\"title\">\n      </div>\n      <div class=\"field required\">\n        <label>内容</label>\n        <textarea name=\"content\" rows=\"15\"></textarea>\n      </div>\n      <input type=\"submit\" class=\"ui button\" value=\"发布\">\n    </form>\n  </div>\n</div>\n\n<%- include('footer') %>\n```\n\n修改 routes/posts.js，将：\n\n```js\n// GET /posts/create 发表文章页\nrouter.get('/create', checkLogin, function (req, res, next) {\n  res.send('发表文章页')\n})\n```\n\n修改为：\n\n```js\n// GET /posts/create 发表文章页\nrouter.get('/create', checkLogin, function (req, res, next) {\n  res.render('create')\n})\n```\n\n登录成功状态，点击右上角『发表文章』试下吧。\n\n发表文章页已经完成了，接下来新建 models/posts.js 用来存放与文章操作相关的代码：\n\n**models/posts.js**\n\n```js\nconst Post = require('../lib/mongo').Post\n\nmodule.exports = {\n  // 创建一篇文章\n  create: function create (post) {\n    return Post.create(post).exec()\n  }\n}\n```\n\n修改 routes/posts.js，在文件上方引入 PostModel：\n\n**routes/posts.js**\n\n```js\nconst PostModel = require('../models/posts')\n```\n\n将：\n\n```js\n// POST /posts/create 发表一篇文章\nrouter.post('/create', checkLogin, function (req, res, next) {\n  res.send('发表文章')\n})\n```\n\n修改为：\n\n```js\n// POST /posts/create 发表一篇文章\nrouter.post('/create', checkLogin, function (req, res, next) {\n  const author = req.session.user._id\n  const title = req.fields.title\n  const content = req.fields.content\n\n  // 校验参数\n  try {\n    if (!title.length) {\n      throw new Error('请填写标题')\n    }\n    if (!content.length) {\n      throw new Error('请填写内容')\n    }\n  } catch (e) {\n    req.flash('error', e.message)\n    return res.redirect('back')\n  }\n\n  let post = {\n    author: author,\n    title: title,\n    content: content\n  }\n\n  PostModel.create(post)\n    .then(function (result) {\n      // 此 post 是插入 mongodb 后的值，包含 _id\n      post = result.ops[0]\n      req.flash('success', '发表成功')\n      // 发表成功后跳转到该文章页\n      res.redirect(`/posts/${post._id}`)\n    })\n    .catch(next)\n})\n```\n\n这里校验了上传的表单字段，并将文章信息插入数据库，成功后跳转到该文章页并显示『发表成功』的通知，失败后请求会进入错误处理函数。\n\n现在刷新页面（登录情况下），点击右上角 `发表文章` 试试吧，发表成功后跳转到了文章页但并没有任何内容，下面我们就来实现文章页及主页。\n\n## 4.9.3 主页与文章页\n\n现在我们来实现主页及文章页。修改 models/posts.js 如下：\n\n**models/posts.js**\n\n```js\nconst marked = require('marked')\nconst Post = require('../lib/mongo').Post\n\n// 将 post 的 content 从 markdown 转换成 html\nPost.plugin('contentToHtml', {\n  afterFind: function (posts) {\n    return posts.map(function (post) {\n      post.content = marked(post.content)\n      return post\n    })\n  },\n  afterFindOne: function (post) {\n    if (post) {\n      post.content = marked(post.content)\n    }\n    return post\n  }\n})\n\nmodule.exports = {\n  // 创建一篇文章\n  create: function create (post) {\n    return Post.create(post).exec()\n  },\n\n  // 通过文章 id 获取一篇文章\n  getPostById: function getPostById (postId) {\n    return Post\n      .findOne({ _id: postId })\n      .populate({ path: 'author', model: 'User' })\n      .addCreatedAt()\n      .contentToHtml()\n      .exec()\n  },\n\n  // 按创建时间降序获取所有用户文章或者某个特定用户的所有文章\n  getPosts: function getPosts (author) {\n    const query = {}\n    if (author) {\n      query.author = author\n    }\n    return Post\n      .find(query)\n      .populate({ path: 'author', model: 'User' })\n      .sort({ _id: -1 })\n      .addCreatedAt()\n      .contentToHtml()\n      .exec()\n  },\n\n  // 通过文章 id 给 pv 加 1\n  incPv: function incPv (postId) {\n    return Post\n      .update({ _id: postId }, { $inc: { pv: 1 } })\n      .exec()\n  }\n}\n```\n\n需要讲解两点：\n\n1. 我们使用了 markdown 解析文章的内容，所以在发表文章的时候可使用 markdown 语法（如插入链接、图片等等），关于 markdown 的使用请参考： [Markdown 语法说明](http://wowubuntu.com/markdown/)。\n2. 我们在 PostModel 上注册了 `contentToHtml`，而 `addCreatedAt` 是在 lib/mongo.js 中 mongolass 上注册的。也就是说 `contentToHtml` 只针对 PostModel 有效，而 `addCreatedAt` 对所有 Model 都有效。\n\n接下来完成主页的模板，修改 views/posts.ejs 如下：\n\n**views/posts.ejs**\n\n```ejs\n<%- include('header') %>\n\n<% posts.forEach(function (post) { %>\n  <%- include('components/post-content', { post: post }) %>\n<% }) %>\n\n<%- include('footer') %>\n```\n\n新建 views/components/post-content.ejs 用来存放单篇文章的模板片段：\n\n**views/components/post-content.ejs**\n\n```ejs\n<div class=\"post-content\">\n  <div class=\"ui grid\">\n    <div class=\"four wide column\">\n      <a class=\"avatar avatar-link\"\n         href=\"/posts?author=<%= post.author._id %>\"\n         data-title=\"<%= post.author.name %> | <%= ({m: '男', f: '女', x: '保密'})[post.author.gender] %>\"\n         data-content=\"<%= post.author.bio %>\">\n        <img class=\"avatar\" src=\"/img/<%= post.author.avatar %>\">\n      </a>\n    </div>\n\n    <div class=\"eight wide column\">\n      <div class=\"ui segment\">\n        <h3><a href=\"/posts/<%= post._id %>\"><%= post.title %></a></h3>\n        <pre><%- post.content %></pre>\n        <div>\n          <span class=\"tag\"><%= post.created_at %></span>\n          <span class=\"tag right\">\n            <span>浏览(<%= post.pv || 0 %>)</span>\n            <span>留言(<%= post.commentsCount || 0 %>)</span>\n\n            <% if (user && post.author._id && user._id.toString() === post.author._id.toString()) { %>\n              <div class=\"ui inline dropdown\">\n                <div class=\"text\"></div>\n                <i class=\"dropdown icon\"></i>\n                <div class=\"menu\">\n                  <div class=\"item\"><a href=\"/posts/<%= post._id %>/edit\">编辑</a></div>\n                  <div class=\"item\"><a href=\"/posts/<%= post._id %>/remove\">删除</a></div>\n                </div>\n              </div>\n            <% } %>\n\n          </span>\n        </div>\n      </div>\n    </div>\n  </div>\n</div>\n```\n\n> 注意：我们用了 `<%- post.content %>`，而不是 `<%= post.content %>`，因为 post.content 是 markdown 转换后的 html 字符串。\n\n修改 routes/posts.js，将：\n\n**routes/posts.js**\n\n```js\nrouter.get('/', function (req, res, next) {\n  res.render('posts')\n})\n```\n\n修改为：\n\n```js\nrouter.get('/', function (req, res, next) {\n  const author = req.query.author\n\n  PostModel.getPosts(author)\n    .then(function (posts) {\n      res.render('posts', {\n        posts: posts\n      })\n    })\n    .catch(next)\n})\n```\n\n> 注意：主页与用户页通过 url 中的 author 区分。\n\n现在完成了主页与用户页，访问 `http://localhost:3000/posts` 试试吧，现在已经将我们之前创建的文章显示出来了，尝试点击用户的头像看看效果。\n\n接下来完成文章详情页。新建 views/post.ejs，添加如下代码：\n\n**views/post.ejs**\n\n```ejs\n<%- include('header') %>\n<%- include('components/post-content') %>\n<%- include('footer') %>\n```\n\n打开 routes/posts.js，将：\n\n**routes/posts.js**\n\n```js\n// GET /posts/:postId 单独一篇的文章页\nrouter.get('/:postId', function (req, res, next) {\n  res.send('文章详情页')\n})\n```\n\n修改为：\n\n```js\n// GET /posts/:postId 单独一篇的文章页\nrouter.get('/:postId', function (req, res, next) {\n  const postId = req.params.postId\n\n  Promise.all([\n    PostModel.getPostById(postId), // 获取文章信息\n    PostModel.incPv(postId)// pv 加 1\n  ])\n    .then(function (result) {\n      const post = result[0]\n      if (!post) {\n        throw new Error('该文章不存在')\n      }\n\n      res.render('post', {\n        post: post\n      })\n    })\n    .catch(next)\n})\n```\n\n现在刷新浏览器，点击文章的标题看看浏览器地址的变化吧。\n\n> 注意：浏览器地址有变化，但页面看不出区别来（因为页面布局一样），后面我们添加留言功能后就能看出区别来了。\n\n## 4.9.4 编辑与删除文章\n\n现在我们来完成编辑与删除文章的功能。修改 models/posts.js，在 module.exports 对象上添加如下 3 个方法：\n\n**models/posts.js**\n\n```js\n// 通过文章 id 获取一篇原生文章（编辑文章）\ngetRawPostById: function getRawPostById (postId) {\n  return Post\n    .findOne({ _id: postId })\n    .populate({ path: 'author', model: 'User' })\n    .exec()\n},\n\n// 通过文章 id 更新一篇文章\nupdatePostById: function updatePostById (postId, data) {\n  return Post.update({ _id: postId }, { $set: data }).exec()\n},\n\n// 通过文章 id 删除一篇文章\ndelPostById: function delPostById (postId) {\n  return Post.deleteOne({ _id: postId }).exec()\n}\n```\n\n> 注意：不要忘了在适当位置添加逗号，如 incPv 的结束大括号后。\n\n> 注意：我们通过新函数 `getRawPostById` 用来获取文章原生的内容（编辑页面用），而不是用 `getPostById` 返回将 markdown 转换成 html 后的内容。\n\n新建编辑文章页 views/edit.ejs，添加如下代码：\n\n**views/edit.ejs**\n\n```js\n<%- include('header') %>\n\n<div class=\"ui grid\">\n  <div class=\"four wide column\">\n    <a class=\"avatar\"\n       href=\"/posts?author=<%= user._id %>\"\n       data-title=\"<%= user.name %> | <%= ({m: '男', f: '女', x: '保密'})[user.gender] %>\"\n       data-content=\"<%= user.bio %>\">\n      <img class=\"avatar\" src=\"/img/<%= user.avatar %>\">\n    </a>\n  </div>\n\n  <div class=\"eight wide column\">\n    <form class=\"ui form segment\" method=\"post\" action=\"/posts/<%= post._id %>/edit\">\n      <div class=\"field required\">\n        <label>标题</label>\n        <input type=\"text\" name=\"title\" value=\"<%= post.title %>\">\n      </div>\n      <div class=\"field required\">\n        <label>内容</label>\n        <textarea name=\"content\" rows=\"15\"><%= post.content %></textarea>\n      </div>\n      <input type=\"submit\" class=\"ui button\" value=\"发布\">\n    </form>\n  </div>\n</div>\n\n<%- include('footer') %>\n```\n\n修改 routes/posts.js，将：\n\n**routes/posts.js**\n\n```js\n// GET /posts/:postId/edit 更新文章页\nrouter.get('/:postId/edit', checkLogin, function (req, res, next) {\n  res.send('更新文章页')\n})\n\n// POST /posts/:postId/edit 更新一篇文章\nrouter.post('/:postId/edit', checkLogin, function (req, res, next) {\n  res.send('更新文章')\n})\n\n// GET /posts/:postId/remove 删除一篇文章\nrouter.get('/:postId/remove', checkLogin, function (req, res, next) {\n  res.send('删除文章')\n})\n```\n\n修改为：\n\n```js\n// GET /posts/:postId/edit 更新文章页\nrouter.get('/:postId/edit', checkLogin, function (req, res, next) {\n  const postId = req.params.postId\n  const author = req.session.user._id\n\n  PostModel.getRawPostById(postId)\n    .then(function (post) {\n      if (!post) {\n        throw new Error('该文章不存在')\n      }\n      if (author.toString() !== post.author._id.toString()) {\n        throw new Error('权限不足')\n      }\n      res.render('edit', {\n        post: post\n      })\n    })\n    .catch(next)\n})\n\n// POST /posts/:postId/edit 更新一篇文章\nrouter.post('/:postId/edit', checkLogin, function (req, res, next) {\n  const postId = req.params.postId\n  const author = req.session.user._id\n  const title = req.fields.title\n  const content = req.fields.content\n\n  // 校验参数\n  try {\n    if (!title.length) {\n      throw new Error('请填写标题')\n    }\n    if (!content.length) {\n      throw new Error('请填写内容')\n    }\n  } catch (e) {\n    req.flash('error', e.message)\n    return res.redirect('back')\n  }\n\n  PostModel.getRawPostById(postId)\n    .then(function (post) {\n      if (!post) {\n        throw new Error('文章不存在')\n      }\n      if (post.author._id.toString() !== author.toString()) {\n        throw new Error('没有权限')\n      }\n      PostModel.updatePostById(postId, { title: title, content: content })\n        .then(function () {\n          req.flash('success', '编辑文章成功')\n          // 编辑成功后跳转到上一页\n          res.redirect(`/posts/${postId}`)\n        })\n        .catch(next)\n    })\n})\n\n// GET /posts/:postId/remove 删除一篇文章\nrouter.get('/:postId/remove', checkLogin, function (req, res, next) {\n  const postId = req.params.postId\n  const author = req.session.user._id\n\n  PostModel.getRawPostById(postId)\n    .then(function (post) {\n      if (!post) {\n        throw new Error('文章不存在')\n      }\n      if (post.author._id.toString() !== author.toString()) {\n        throw new Error('没有权限')\n      }\n      PostModel.delPostById(postId)\n        .then(function () {\n          req.flash('success', '删除文章成功')\n          // 删除成功后跳转到主页\n          res.redirect('/posts')\n        })\n        .catch(next)\n    })\n})\n```\n\n现在刷新主页，点击文章右下角的小三角，编辑文章和删除文章试试吧。\n\n上一节：[4.8 登出与登录](https://github.com/nswbmw/N-blog/blob/master/book/4.8%20%E7%99%BB%E5%87%BA%E4%B8%8E%E7%99%BB%E5%BD%95.md)\n\n下一节：[4.10 留言](https://github.com/nswbmw/N-blog/blob/master/book/4.10%20%E7%95%99%E8%A8%80.md)\n"
  },
  {
    "path": "config/default.js",
    "content": "module.exports = {\n  port: 3000,\n  session: {\n    secret: 'myblog',\n    key: 'myblog',\n    maxAge: 2592000000\n  },\n  mongodb: 'mongodb://localhost:27017/myblog'\n}\n"
  },
  {
    "path": "index.js",
    "content": "const path = require('path')\nconst express = require('express')\nconst session = require('express-session')\nconst MongoStore = require('connect-mongo')(session)\nconst flash = require('connect-flash')\nconst config = require('config-lite')(__dirname)\nconst routes = require('./routes')\nconst pkg = require('./package')\nconst winston = require('winston')\nconst expressWinston = require('express-winston')\n\nconst app = express()\n\n// 设置模板目录\napp.set('views', path.join(__dirname, 'views'))\n// 设置模板引擎为 ejs\napp.set('view engine', 'ejs')\n\n// 设置静态文件目录\napp.use(express.static(path.join(__dirname, 'public')))\n// session 中间件\napp.use(session({\n  name: config.session.key, // 设置 cookie 中保存 session id 的字段名称\n  secret: config.session.secret, // 通过设置 secret 来计算 hash 值并放在 cookie 中，使产生的 signedCookie 防篡改\n  resave: true, // 强制更新 session\n  saveUninitialized: false, // 设置为 false，强制创建一个 session，即使用户未登录\n  cookie: {\n    maxAge: config.session.maxAge// 过期时间，过期后 cookie 中的 session id 自动删除\n  },\n  store: new MongoStore({// 将 session 存储到 mongodb\n    url: config.mongodb// mongodb 地址\n  })\n}))\n// flash 中间件，用来显示通知\napp.use(flash())\n\n// 处理表单及文件上传的中间件\napp.use(require('express-formidable')({\n  uploadDir: path.join(__dirname, 'public/img'), // 上传文件目录\n  keepExtensions: true// 保留后缀\n}))\n\n// 设置模板全局常量\napp.locals.blog = {\n  title: pkg.name,\n  description: pkg.description\n}\n\n// 添加模板必需的三个变量\napp.use(function (req, res, next) {\n  res.locals.user = req.session.user\n  res.locals.success = req.flash('success').toString()\n  res.locals.error = req.flash('error').toString()\n  next()\n})\n\n// 正常请求的日志\napp.use(expressWinston.logger({\n  transports: [\n    new (winston.transports.Console)({\n      json: true,\n      colorize: true\n    }),\n    new winston.transports.File({\n      filename: 'logs/success.log'\n    })\n  ]\n}))\n// 路由\nroutes(app)\n// 错误请求的日志\napp.use(expressWinston.errorLogger({\n  transports: [\n    new winston.transports.Console({\n      json: true,\n      colorize: true\n    }),\n    new winston.transports.File({\n      filename: 'logs/error.log'\n    })\n  ]\n}))\n\napp.use(function (err, req, res, next) {\n  console.error(err)\n  req.flash('error', err.message)\n  res.redirect('/posts')\n})\n\nif (module.parent) {\n  // 被 require，则导出 app\n  module.exports = app\n} else {\n  // 监听端口，启动程序\n  app.listen(config.port, function () {\n    console.log(`${pkg.name} listening on port ${config.port}`)\n  })\n}\n"
  },
  {
    "path": "lib/mongo.js",
    "content": "const config = require('config-lite')(__dirname)\nconst Mongolass = require('mongolass')\nconst mongolass = new Mongolass()\nmongolass.connect(config.mongodb)\n\nexports.User = mongolass.model('User', {\n  name: { type: 'string', required: true },\n  password: { type: 'string', required: true },\n  avatar: { type: 'string', required: true },\n  gender: { type: 'string', enum: ['m', 'f', 'x'], default: 'x' },\n  bio: { type: 'string', required: true }\n})\nexports.User.index({ name: 1 }, { unique: true }).exec()// 根据用户名找到用户，用户名全局唯一\n\nconst moment = require('moment')\nconst objectIdToTimestamp = require('objectid-to-timestamp')\n\n// 根据 id 生成创建时间 created_at\nmongolass.plugin('addCreatedAt', {\n  afterFind: function (results) {\n    results.forEach(function (item) {\n      item.created_at = moment(objectIdToTimestamp(item._id)).format('YYYY-MM-DD HH:mm')\n    })\n    return results\n  },\n  afterFindOne: function (result) {\n    if (result) {\n      result.created_at = moment(objectIdToTimestamp(result._id)).format('YYYY-MM-DD HH:mm')\n    }\n    return result\n  }\n})\n\nexports.Post = mongolass.model('Post', {\n  author: { type: Mongolass.Types.ObjectId, required: true },\n  title: { type: 'string', required: true },\n  content: { type: 'string', required: true },\n  pv: { type: 'number', default: 0 }\n})\nexports.Post.index({ author: 1, _id: -1 }).exec()// 按创建时间降序查看用户的文章列表\n\nexports.Comment = mongolass.model('Comment', {\n  author: { type: Mongolass.Types.ObjectId, required: true },\n  content: { type: 'string', required: true },\n  postId: { type: Mongolass.Types.ObjectId, required: true }\n})\nexports.Comment.index({ postId: 1, _id: 1 }).exec()// 通过文章 id 获取该文章下所有留言，按留言创建时间升序\n"
  },
  {
    "path": "logs/.gitignore",
    "content": "# Ignore everything in this directory\n*\n# Except this file\n!.gitignore"
  },
  {
    "path": "middlewares/check.js",
    "content": "module.exports = {\n  checkLogin: function checkLogin (req, res, next) {\n    if (!req.session.user) {\n      req.flash('error', '未登录')\n      return res.redirect('/signin')\n    }\n    next()\n  },\n\n  checkNotLogin: function checkNotLogin (req, res, next) {\n    if (req.session.user) {\n      req.flash('error', '已登录')\n      return res.redirect('back')// 返回之前的页面\n    }\n    next()\n  }\n}\n"
  },
  {
    "path": "models/comments.js",
    "content": "const marked = require('marked')\nconst Comment = require('../lib/mongo').Comment\n\n// 将 comment 的 content 从 markdown 转换成 html\nComment.plugin('contentToHtml', {\n  afterFind: function (comments) {\n    return comments.map(function (comment) {\n      comment.content = marked(comment.content)\n      return comment\n    })\n  }\n})\n\nmodule.exports = {\n  // 创建一个留言\n  create: function create (comment) {\n    return Comment.create(comment).exec()\n  },\n\n  // 通过留言 id 获取一个留言\n  getCommentById: function getCommentById (commentId) {\n    return Comment.findOne({ _id: commentId }).exec()\n  },\n\n  // 通过留言 id 删除一个留言\n  delCommentById: function delCommentById (commentId) {\n    return Comment.deleteOne({ _id: commentId }).exec()\n  },\n\n  // 通过文章 id 删除该文章下所有留言\n  delCommentsByPostId: function delCommentsByPostId (postId) {\n    return Comment.deleteMany({ postId: postId }).exec()\n  },\n\n  // 通过文章 id 获取该文章下所有留言，按留言创建时间升序\n  getComments: function getComments (postId) {\n    return Comment\n      .find({ postId: postId })\n      .populate({ path: 'author', model: 'User' })\n      .sort({ _id: 1 })\n      .addCreatedAt()\n      .contentToHtml()\n      .exec()\n  },\n\n  // 通过文章 id 获取该文章下留言数\n  getCommentsCount: function getCommentsCount (postId) {\n    return Comment.count({ postId: postId }).exec()\n  }\n}\n"
  },
  {
    "path": "models/posts.js",
    "content": "const marked = require('marked')\nconst Post = require('../lib/mongo').Post\nconst CommentModel = require('./comments')\n\n// 给 post 添加留言数 commentsCount\nPost.plugin('addCommentsCount', {\n  afterFind: function (posts) {\n    return Promise.all(posts.map(function (post) {\n      return CommentModel.getCommentsCount(post._id).then(function (commentsCount) {\n        post.commentsCount = commentsCount\n        return post\n      })\n    }))\n  },\n  afterFindOne: function (post) {\n    if (post) {\n      return CommentModel.getCommentsCount(post._id).then(function (count) {\n        post.commentsCount = count\n        return post\n      })\n    }\n    return post\n  }\n})\n\n// 将 post 的 content 从 markdown 转换成 html\nPost.plugin('contentToHtml', {\n  afterFind: function (posts) {\n    return posts.map(function (post) {\n      post.content = marked(post.content)\n      return post\n    })\n  },\n  afterFindOne: function (post) {\n    if (post) {\n      post.content = marked(post.content)\n    }\n    return post\n  }\n})\n\nmodule.exports = {\n  // 创建一篇文章\n  create: function create (post) {\n    return Post.create(post).exec()\n  },\n\n  // 通过文章 id 获取一篇文章\n  getPostById: function getPostById (postId) {\n    return Post\n      .findOne({ _id: postId })\n      .populate({ path: 'author', model: 'User' })\n      .addCreatedAt()\n      .addCommentsCount()\n      .contentToHtml()\n      .exec()\n  },\n\n  // 按创建时间降序获取所有用户文章或者某个特定用户的所有文章\n  getPosts: function getPosts (author) {\n    const query = {}\n    if (author) {\n      query.author = author\n    }\n    return Post\n      .find(query)\n      .populate({ path: 'author', model: 'User' })\n      .sort({ _id: -1 })\n      .addCreatedAt()\n      .addCommentsCount()\n      .contentToHtml()\n      .exec()\n  },\n\n  // 通过文章 id 给 pv 加 1\n  incPv: function incPv (postId) {\n    return Post\n      .update({ _id: postId }, { $inc: { pv: 1 } })\n      .exec()\n  },\n\n  // 通过文章 id 获取一篇原生文章（编辑文章）\n  getRawPostById: function getRawPostById (postId) {\n    return Post\n      .findOne({ _id: postId })\n      .populate({ path: 'author', model: 'User' })\n      .exec()\n  },\n\n  // 通过文章 id 更新一篇文章\n  updatePostById: function updatePostById (postId, data) {\n    return Post.update({ _id: postId }, { $set: data }).exec()\n  },\n\n  // 通过文章 id 删除一篇文章\n  delPostById: function delPostById (postId) {\n    return Post.deleteOne({ _id: postId })\n      .exec()\n      .then(function (res) {\n        // 文章删除后，再删除该文章下的所有留言\n        if (res.result.ok && res.result.n > 0) {\n          return CommentModel.delCommentsByPostId(postId)\n        }\n      })\n  }\n}\n"
  },
  {
    "path": "models/users.js",
    "content": "const User = require('../lib/mongo').User\n\nmodule.exports = {\n  // 注册一个用户\n  create: function create (user) {\n    return User.create(user).exec()\n  },\n\n  // 通过用户名获取用户信息\n  getUserByName: function getUserByName (name) {\n    return User\n      .findOne({ name: name })\n      .addCreatedAt()\n      .exec()\n  }\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"myblog\",\n  \"version\": \"1.0.0\",\n  \"description\": \"my first blog\",\n  \"main\": \"index.js\",\n  \"scripts\": {\n    \"test\": \"istanbul cover _mocha\",\n    \"start\": \"cross-env NODE_ENV=production pm2 start index.js --name 'myblog'\",\n    \"stop\": \"cross-env NODE_ENV=production pm2 stop myblog\",\n    \"lint\": \"eslint --fix config lib middlewares models routes test\"\n  },\n  \"author\": \"nswbmw\",\n  \"license\": \"MIT\",\n  \"dependencies\": {\n    \"config-lite\": \"2.1.0\",\n    \"connect-flash\": \"0.1.1\",\n    \"connect-mongo\": \"2.0.1\",\n    \"ejs\": \"2.5.7\",\n    \"express\": \"4.16.2\",\n    \"express-formidable\": \"git+https://github.com/utatti/express-formidable.git\",\n    \"express-session\": \"1.15.6\",\n    \"express-winston\": \"2.4.0\",\n    \"marked\": \"0.3.12\",\n    \"moment\": \"2.20.1\",\n    \"mongolass\": \"~4.1.1\",\n    \"objectid-to-timestamp\": \"1.3.0\",\n    \"sha1\": \"1.1.1\",\n    \"winston\": \"2.4.0\"\n  },\n  \"devDependencies\": {\n    \"cross-env\": \"5.2.0\",\n    \"eslint\": \"5.5.0\",\n    \"eslint-config-standard\": \"11.0.0-beta.0\",\n    \"eslint-plugin-import\": \"2.8.0\",\n    \"eslint-plugin-node\": \"5.2.1\",\n    \"eslint-plugin-promise\": \"3.6.0\",\n    \"eslint-plugin-standard\": \"3.0.1\",\n    \"istanbul\": \"0.4.5\",\n    \"mocha\": \"4.1.0\",\n    \"pm2\": \"3.0.0\",\n    \"supertest\": \"3.0.0\"\n  }\n}\n"
  },
  {
    "path": "public/css/style.css",
    "content": "/* ---------- 全局样式 ---------- */\n\nbody {\n  width: 1100px;\n  height: 100%;\n  margin: 0 auto;\n  padding-top: 40px;\n}\n\na:hover {\n  border-bottom: 3px solid #4fc08d;\n}\n\n.button {\n  background-color: #4fc08d !important;\n  color: #fff !important;\n}\n\n.avatar {\n  border-radius: 3px;\n  width: 48px;\n  height: 48px;\n  float: right;\n}\n\n/* ---------- nav ---------- */\n\n.nav {\n  margin-bottom: 20px;\n  color: #999;\n  text-align: center;\n}\n\n.nav h1 {\n  color: #4fc08d;\n  display: inline-block;\n  margin: 10px 0;\n}\n\n/* ---------- nav-setting ---------- */\n\n.nav-setting {\n  position: fixed;\n  right: 30px;\n  top: 35px;\n  z-index: 999;\n}\n\n.nav-setting .ui.dropdown.button {\n  padding: 10px 10px 0 10px;\n  background-color: #fff !important;\n}\n\n.nav-setting .icon.bars {\n  color: #000;\n  font-size: 18px;\n}\n\n/* ---------- post-content ---------- */\n\n.post-content h3 a {\n  color: #4fc08d !important;\n}\n\n.post-content .tag {\n  font-size: 13px;\n  margin-right: 5px;\n  color: #999;\n}\n\n.post-content .tag.right {\n  float: right;\n  margin-right: 0;\n}\n\n.post-content .tag.right a {\n  color: #999;\n}"
  },
  {
    "path": "public/img/.gitignore",
    "content": "# Ignore everything in this directory\n*\n# Except this file\n!.gitignore"
  },
  {
    "path": "routes/comments.js",
    "content": "const express = require('express')\nconst router = express.Router()\n\nconst checkLogin = require('../middlewares/check').checkLogin\nconst CommentModel = require('../models/comments')\n\n// POST /comments 创建一条留言\nrouter.post('/', checkLogin, function (req, res, next) {\n  const author = req.session.user._id\n  const postId = req.fields.postId\n  const content = req.fields.content\n\n  // 校验参数\n  try {\n    if (!content.length) {\n      throw new Error('请填写留言内容')\n    }\n  } catch (e) {\n    req.flash('error', e.message)\n    return res.redirect('back')\n  }\n\n  const comment = {\n    author: author,\n    postId: postId,\n    content: content\n  }\n\n  CommentModel.create(comment)\n    .then(function () {\n      req.flash('success', '留言成功')\n      // 留言成功后跳转到上一页\n      res.redirect('back')\n    })\n    .catch(next)\n})\n\n// GET /comments/:commentId/remove 删除一条留言\nrouter.get('/:commentId/remove', checkLogin, function (req, res, next) {\n  const commentId = req.params.commentId\n  const author = req.session.user._id\n\n  CommentModel.getCommentById(commentId)\n    .then(function (comment) {\n      if (!comment) {\n        throw new Error('留言不存在')\n      }\n      if (comment.author.toString() !== author.toString()) {\n        throw new Error('没有权限删除留言')\n      }\n      CommentModel.delCommentById(commentId)\n        .then(function () {\n          req.flash('success', '删除留言成功')\n          // 删除成功后跳转到上一页\n          res.redirect('back')\n        })\n        .catch(next)\n    })\n})\n\nmodule.exports = router\n"
  },
  {
    "path": "routes/index.js",
    "content": "module.exports = function (app) {\n  app.get('/', function (req, res) {\n    res.redirect('/posts')\n  })\n  app.use('/signup', require('./signup'))\n  app.use('/signin', require('./signin'))\n  app.use('/signout', require('./signout'))\n  app.use('/posts', require('./posts'))\n  app.use('/comments', require('./comments'))\n\n  // 404 page\n  app.use(function (req, res) {\n    if (!res.headersSent) {\n      res.status(404).render('404')\n    }\n  })\n}\n"
  },
  {
    "path": "routes/posts.js",
    "content": "const express = require('express')\nconst router = express.Router()\n\nconst checkLogin = require('../middlewares/check').checkLogin\nconst PostModel = require('../models/posts')\nconst CommentModel = require('../models/comments')\n\n// GET /posts 所有用户或者特定用户的文章页\n//   eg: GET /posts?author=xxx\nrouter.get('/', function (req, res, next) {\n  const author = req.query.author\n\n  PostModel.getPosts(author)\n    .then(function (posts) {\n      res.render('posts', {\n        posts: posts\n      })\n    })\n    .catch(next)\n})\n\n// POST /posts/create 发表一篇文章\nrouter.post('/create', checkLogin, function (req, res, next) {\n  const author = req.session.user._id\n  const title = req.fields.title\n  const content = req.fields.content\n\n  // 校验参数\n  try {\n    if (!title.length) {\n      throw new Error('请填写标题')\n    }\n    if (!content.length) {\n      throw new Error('请填写内容')\n    }\n  } catch (e) {\n    req.flash('error', e.message)\n    return res.redirect('back')\n  }\n\n  let post = {\n    author: author,\n    title: title,\n    content: content\n  }\n\n  PostModel.create(post)\n    .then(function (result) {\n      // 此 post 是插入 mongodb 后的值，包含 _id\n      post = result.ops[0]\n      req.flash('success', '发表成功')\n      // 发表成功后跳转到该文章页\n      res.redirect(`/posts/${post._id}`)\n    })\n    .catch(next)\n})\n\n// GET /posts/create 发表文章页\nrouter.get('/create', checkLogin, function (req, res, next) {\n  res.render('create')\n})\n\n// GET /posts/:postId 单独一篇的文章页\nrouter.get('/:postId', function (req, res, next) {\n  const postId = req.params.postId\n\n  Promise.all([\n    PostModel.getPostById(postId), // 获取文章信息\n    CommentModel.getComments(postId), // 获取该文章所有留言\n    PostModel.incPv(postId)// pv 加 1\n  ])\n    .then(function (result) {\n      const post = result[0]\n      const comments = result[1]\n      if (!post) {\n        throw new Error('该文章不存在')\n      }\n\n      res.render('post', {\n        post: post,\n        comments: comments\n      })\n    })\n    .catch(next)\n})\n\n// GET /posts/:postId/edit 更新文章页\nrouter.get('/:postId/edit', checkLogin, function (req, res, next) {\n  const postId = req.params.postId\n  const author = req.session.user._id\n\n  PostModel.getRawPostById(postId)\n    .then(function (post) {\n      if (!post) {\n        throw new Error('该文章不存在')\n      }\n      if (author.toString() !== post.author._id.toString()) {\n        throw new Error('权限不足')\n      }\n      res.render('edit', {\n        post: post\n      })\n    })\n    .catch(next)\n})\n\n// POST /posts/:postId/edit 更新一篇文章\nrouter.post('/:postId/edit', checkLogin, function (req, res, next) {\n  const postId = req.params.postId\n  const author = req.session.user._id\n  const title = req.fields.title\n  const content = req.fields.content\n\n  // 校验参数\n  try {\n    if (!title.length) {\n      throw new Error('请填写标题')\n    }\n    if (!content.length) {\n      throw new Error('请填写内容')\n    }\n  } catch (e) {\n    req.flash('error', e.message)\n    return res.redirect('back')\n  }\n\n  PostModel.getRawPostById(postId)\n    .then(function (post) {\n      if (!post) {\n        throw new Error('文章不存在')\n      }\n      if (post.author._id.toString() !== author.toString()) {\n        throw new Error('没有权限')\n      }\n\n      PostModel.updatePostById(postId, { title: title, content: content })\n        .then(function () {\n          req.flash('success', '编辑文章成功')\n          // 编辑成功后跳转到上一页\n          res.redirect(`/posts/${postId}`)\n        })\n        .catch(next)\n    })\n})\n\n// GET /posts/:postId/remove 删除一篇文章\nrouter.get('/:postId/remove', checkLogin, function (req, res, next) {\n  const postId = req.params.postId\n  const author = req.session.user._id\n\n  PostModel.getRawPostById(postId)\n    .then(function (post) {\n      if (!post) {\n        throw new Error('文章不存在')\n      }\n      if (post.author._id.toString() !== author.toString()) {\n        throw new Error('没有权限')\n      }\n      PostModel.delPostById(postId)\n        .then(function () {\n          req.flash('success', '删除文章成功')\n          // 删除成功后跳转到主页\n          res.redirect('/posts')\n        })\n        .catch(next)\n    })\n})\n\nmodule.exports = router\n"
  },
  {
    "path": "routes/signin.js",
    "content": "const sha1 = require('sha1')\nconst express = require('express')\nconst router = express.Router()\n\nconst UserModel = require('../models/users')\nconst checkNotLogin = require('../middlewares/check').checkNotLogin\n\n// GET /signin 登录页\nrouter.get('/', checkNotLogin, function (req, res, next) {\n  res.render('signin')\n})\n\n// POST /signin 用户登录\nrouter.post('/', checkNotLogin, function (req, res, next) {\n  const name = req.fields.name\n  const password = req.fields.password\n\n  // 校验参数\n  try {\n    if (!name.length) {\n      throw new Error('请填写用户名')\n    }\n    if (!password.length) {\n      throw new Error('请填写密码')\n    }\n  } catch (e) {\n    req.flash('error', e.message)\n    return res.redirect('back')\n  }\n\n  UserModel.getUserByName(name)\n    .then(function (user) {\n      if (!user) {\n        req.flash('error', '用户不存在')\n        return res.redirect('back')\n      }\n      // 检查密码是否匹配\n      if (sha1(password) !== user.password) {\n        req.flash('error', '用户名或密码错误')\n        return res.redirect('back')\n      }\n      req.flash('success', '登录成功')\n      // 用户信息写入 session\n      delete user.password\n      req.session.user = user\n      // 跳转到主页\n      res.redirect('/posts')\n    })\n    .catch(next)\n})\n\nmodule.exports = router\n"
  },
  {
    "path": "routes/signout.js",
    "content": "const express = require('express')\nconst router = express.Router()\n\nconst checkLogin = require('../middlewares/check').checkLogin\n\n// GET /signout 登出\nrouter.get('/', checkLogin, function (req, res, next) {\n  // 清空 session 中用户信息\n  req.session.user = null\n  req.flash('success', '登出成功')\n  // 登出成功后跳转到主页\n  res.redirect('/posts')\n})\n\nmodule.exports = router\n"
  },
  {
    "path": "routes/signup.js",
    "content": "const fs = require('fs')\nconst path = require('path')\nconst sha1 = require('sha1')\nconst express = require('express')\nconst router = express.Router()\n\nconst UserModel = require('../models/users')\nconst checkNotLogin = require('../middlewares/check').checkNotLogin\n\n// GET /signup 注册页\nrouter.get('/', checkNotLogin, function (req, res, next) {\n  res.render('signup')\n})\n\n// POST /signup 用户注册\nrouter.post('/', checkNotLogin, function (req, res, next) {\n  const name = req.fields.name\n  const gender = req.fields.gender\n  const bio = req.fields.bio\n  const avatar = req.files.avatar.path.split(path.sep).pop()\n  let password = req.fields.password\n  const repassword = req.fields.repassword\n\n  // 校验参数\n  try {\n    if (!(name.length >= 1 && name.length <= 10)) {\n      throw new Error('名字请限制在 1-10 个字符')\n    }\n    if (['m', 'f', 'x'].indexOf(gender) === -1) {\n      throw new Error('性别只能是 m、f 或 x')\n    }\n    if (!(bio.length >= 1 && bio.length <= 30)) {\n      throw new Error('个人简介请限制在 1-30 个字符')\n    }\n    if (!req.files.avatar.name) {\n      throw new Error('缺少头像')\n    }\n    if (password.length < 6) {\n      throw new Error('密码至少 6 个字符')\n    }\n    if (password !== repassword) {\n      throw new Error('两次输入密码不一致')\n    }\n  } catch (e) {\n    // 注册失败，异步删除上传的头像\n    fs.unlink(req.files.avatar.path)\n    req.flash('error', e.message)\n    return res.redirect('/signup')\n  }\n\n  // 明文密码加密\n  password = sha1(password)\n\n  // 待写入数据库的用户信息\n  let user = {\n    name: name,\n    password: password,\n    gender: gender,\n    bio: bio,\n    avatar: avatar\n  }\n  // 用户信息写入数据库\n  UserModel.create(user)\n    .then(function (result) {\n      // 此 user 是插入 mongodb 后的值，包含 _id\n      user = result.ops[0]\n      // 删除密码这种敏感信息，将用户信息存入 session\n      delete user.password\n      req.session.user = user\n      // 写入 flash\n      req.flash('success', '注册成功')\n      // 跳转到首页\n      res.redirect('/posts')\n    })\n    .catch(function (e) {\n      // 注册失败，异步删除上传的头像\n      fs.unlink(req.files.avatar.path)\n      // 用户名被占用则跳回注册页，而不是错误页\n      if (e.message.match('duplicate key')) {\n        req.flash('error', '用户名已被占用')\n        return res.redirect('/signup')\n      }\n      next(e)\n    })\n})\n\nmodule.exports = router\n"
  },
  {
    "path": "test/signup.js",
    "content": "const path = require('path')\nconst assert = require('assert')\nconst request = require('supertest')\nconst app = require('../index')\nconst User = require('../lib/mongo').User\n\nconst testName1 = 'testName1'\nconst testName2 = 'nswbmw'\ndescribe('signup', function () {\n  describe('POST /signup', function () {\n    const agent = request.agent(app)// persist cookie when redirect\n    beforeEach(function (done) {\n      // 创建一个用户\n      User.create({\n        name: testName1,\n        password: '123456',\n        avatar: '',\n        gender: 'x',\n        bio: ''\n      })\n        .exec()\n        .then(function () {\n          done()\n        })\n        .catch(done)\n    })\n\n    afterEach(function (done) {\n      // 删除测试用户\n      User.deleteMany({ name: { $in: [testName1, testName2] } })\n        .exec()\n        .then(function () {\n          done()\n        })\n        .catch(done)\n    })\n\n    after(function (done) {\n      process.exit()\n    })\n\n    // 用户名错误的情况\n    it('wrong name', function (done) {\n      agent\n        .post('/signup')\n        .type('form')\n        .field({ name: '' })\n        .attach('avatar', path.join(__dirname, 'avatar.png'))\n        .redirects()\n        .end(function (err, res) {\n          if (err) return done(err)\n          assert(res.text.match(/名字请限制在 1-10 个字符/))\n          done()\n        })\n    })\n\n    // 性别错误的情况\n    it('wrong gender', function (done) {\n      agent\n        .post('/signup')\n        .type('form')\n        .field({ name: testName2, gender: 'a' })\n        .attach('avatar', path.join(__dirname, 'avatar.png'))\n        .redirects()\n        .end(function (err, res) {\n          if (err) return done(err)\n          assert(res.text.match(/性别只能是 m、f 或 x/))\n          done()\n        })\n    })\n    // 其余的参数测试自行补充\n    // 用户名被占用的情况\n    it('duplicate name', function (done) {\n      agent\n        .post('/signup')\n        .type('form')\n        .field({ name: testName1, gender: 'm', bio: 'noder', password: '123456', repassword: '123456' })\n        .attach('avatar', path.join(__dirname, 'avatar.png'))\n        .redirects()\n        .end(function (err, res) {\n          if (err) return done(err)\n          assert(res.text.match(/用户名已被占用/))\n          done()\n        })\n    })\n\n    // 注册成功的情况\n    it('success', function (done) {\n      agent\n        .post('/signup')\n        .type('form')\n        .field({ name: testName2, gender: 'm', bio: 'noder', password: '123456', repassword: '123456' })\n        .attach('avatar', path.join(__dirname, 'avatar.png'))\n        .redirects()\n        .end(function (err, res) {\n          if (err) return done(err)\n          assert(res.text.match(/注册成功/))\n          done()\n        })\n    })\n  })\n})\n"
  },
  {
    "path": "views/404.ejs",
    "content": "<!DOCTYPE html>\n<html>\n  <head>\n    <meta charset=\"utf-8\">\n    <title><%= blog.title %></title>\n    <script type=\"text/javascript\" src=\"http://www.qq.com/404/search_children.js\" charset=\"utf-8\"></script>\n  </head>\n  <body></body>\n</html>\n"
  },
  {
    "path": "views/components/comments.ejs",
    "content": "<div class=\"ui grid\">\n  <div class=\"four wide column\"></div>\n  <div class=\"eight wide column\">\n    <div class=\"ui segment\">\n      <div class=\"ui minimal comments\">\n        <h3 class=\"ui dividing header\">留言</h3>\n\n        <% comments.forEach(function (comment) { %>\n          <div class=\"comment\">\n            <span class=\"avatar\">\n              <img src=\"/img/<%= comment.author.avatar %>\">\n            </span>\n            <div class=\"content\">\n              <a class=\"author\" href=\"/posts?author=<%= comment.author._id %>\"><%= comment.author.name %></a>\n              <div class=\"metadata\">\n                <span class=\"date\"><%= comment.created_at %></span>\n              </div>\n              <div class=\"text\"><%- comment.content %></div>\n\n              <% if (user && comment.author._id && user._id.toString() === comment.author._id.toString()) { %>\n                <div class=\"actions\">\n                  <a class=\"reply\" href=\"/comments/<%= comment._id %>/remove\">删除</a>\n                </div>\n              <% } %>\n            </div>\n          </div>\n        <% }) %>\n\n        <% if (user) { %>\n          <form class=\"ui reply form\" method=\"post\" action=\"/comments\">\n            <input name=\"postId\" value=\"<%= post._id %>\" hidden>\n            <div class=\"field\">\n              <textarea name=\"content\"></textarea>\n            </div>\n            <input type=\"submit\" class=\"ui icon button\" value=\"留言\" />\n          </form>\n        <% } %>\n\n      </div>\n    </div>\n  </div>\n</div>\n"
  },
  {
    "path": "views/components/nav-setting.ejs",
    "content": "<div class=\"nav-setting\">\n  <div class=\"ui buttons\">\n    <div class=\"ui floating dropdown button\">\n      <i class=\"icon bars\"></i>\n      <div class=\"menu\">\n        <% if (user) { %>\n          <a class=\"item\" href=\"/posts?author=<%= user._id %>\">个人主页</a>\n          <div class=\"divider\"></div>\n          <a class=\"item\" href=\"/posts/create\">发表文章</a>\n          <a class=\"item\" href=\"/signout\">登出</a>\n        <% } else { %>\n          <a class=\"item\" href=\"/signin\">登录</a>\n          <a class=\"item\" href=\"/signup\">注册</a>\n        <% } %>\n      </div>\n    </div>\n  </div>\n</div>\n"
  },
  {
    "path": "views/components/nav.ejs",
    "content": "<div class=\"nav\">\n  <div class=\"ui grid\">\n    <div class=\"four wide column\"></div>\n\n    <div class=\"eight wide column\">\n      <a href=\"/posts\"><h1><%= blog.title %></h1></a>\n      <p><%= blog.description %></p>\n    </div>\n  </div>\n</div>"
  },
  {
    "path": "views/components/notification.ejs",
    "content": "<div class=\"ui grid\">\n  <div class=\"four wide column\"></div>\n  <div class=\"eight wide column\">\n\n  <% if (success) { %>\n    <div class=\"ui success message\">\n      <p><%= success %></p>\n    </div>\n  <% } %>\n\n  <% if (error) { %>\n    <div class=\"ui error message\">\n      <p><%= error %></p>\n    </div>\n  <% } %>\n\n  </div>\n</div>"
  },
  {
    "path": "views/components/post-content.ejs",
    "content": "<div class=\"post-content\">\n  <div class=\"ui grid\">\n    <div class=\"four wide column\">\n      <a class=\"avatar avatar-link\"\n         href=\"/posts?author=<%= post.author._id %>\"\n         data-title=\"<%= post.author.name %> | <%= ({m: '男', f: '女', x: '保密'})[post.author.gender] %>\"\n         data-content=\"<%= post.author.bio %>\">\n        <img class=\"avatar\" src=\"/img/<%= post.author.avatar %>\">\n      </a>\n    </div>\n\n    <div class=\"eight wide column\">\n      <div class=\"ui segment\">\n        <h3><a href=\"/posts/<%= post._id %>\"><%= post.title %></a></h3>\n        <pre><%- post.content %></pre>\n        <div>\n          <span class=\"tag\"><%= post.created_at %></span>\n          <span class=\"tag right\">\n            <span>浏览(<%= post.pv || 0 %>)</span>\n            <span>留言(<%= post.commentsCount || 0 %>)</span>\n\n            <% if (user && post.author._id && user._id.toString() === post.author._id.toString()) { %>\n              <div class=\"ui inline dropdown\">\n                <div class=\"text\"></div>\n                <i class=\"dropdown icon\"></i>\n                <div class=\"menu\">\n                  <div class=\"item\"><a href=\"/posts/<%= post._id %>/edit\">编辑</a></div>\n                  <div class=\"item\"><a href=\"/posts/<%= post._id %>/remove\">删除</a></div>\n                </div>\n              </div>\n            <% } %>\n\n          </span>\n        </div>\n      </div>\n    </div>\n  </div>\n</div>\n"
  },
  {
    "path": "views/create.ejs",
    "content": "<%- include('header') %>\n\n<div class=\"ui grid\">\n  <div class=\"four wide column\">\n    <a class=\"avatar avatar-link\"\n       href=\"/posts?author=<%= user._id %>\"\n       data-title=\"<%= user.name %> | <%= ({m: '男', f: '女', x: '保密'})[user.gender] %>\"\n       data-content=\"<%= user.bio %>\">\n      <img class=\"avatar\" src=\"/img/<%= user.avatar %>\">\n    </a>\n  </div>\n\n  <div class=\"eight wide column\">\n    <form class=\"ui form segment\" method=\"post\">\n      <div class=\"field required\">\n        <label>标题</label>\n        <input type=\"text\" name=\"title\">\n      </div>\n      <div class=\"field required\">\n        <label>内容</label>\n        <textarea name=\"content\" rows=\"15\"></textarea>\n      </div>\n      <input type=\"submit\" class=\"ui button\" value=\"发布\">\n    </form>\n  </div>\n</div>\n\n<%- include('footer') %>\n"
  },
  {
    "path": "views/edit.ejs",
    "content": "<%- include('header') %>\n\n<div class=\"ui grid\">\n  <div class=\"four wide column\">\n    <a class=\"avatar\"\n       href=\"/posts?author=<%= user._id %>\"\n       data-title=\"<%= user.name %> | <%= ({m: '男', f: '女', x: '保密'})[user.gender] %>\"\n       data-content=\"<%= user.bio %>\">\n      <img class=\"avatar\" src=\"/img/<%= user.avatar %>\">\n    </a>\n  </div>\n\n  <div class=\"eight wide column\">\n    <form class=\"ui form segment\" method=\"post\" action=\"/posts/<%= post._id %>/edit\">\n      <div class=\"field required\">\n        <label>标题</label>\n        <input type=\"text\" name=\"title\" value=\"<%= post.title %>\">\n      </div>\n      <div class=\"field required\">\n        <label>内容</label>\n        <textarea name=\"content\" rows=\"15\"><%= post.content %></textarea>\n      </div>\n      <input type=\"submit\" class=\"ui button\" value=\"发布\">\n    </form>\n  </div>\n</div>\n\n<%- include('footer') %>\n"
  },
  {
    "path": "views/footer.ejs",
    "content": "  <script type=\"text/javascript\">\n   $(document).ready(function () {\n      // 点击按钮弹出下拉框\n      $('.ui.dropdown').dropdown();\n\n      // 鼠标悬浮在头像上，弹出气泡提示框\n      $('.post-content .avatar-link').popup({\n        inline: true,\n        position: 'bottom right',\n        lastResort: 'bottom right'\n      });\n    })\n  </script>\n  </body>\n</html>\n"
  },
  {
    "path": "views/header.ejs",
    "content": "<!DOCTYPE html>\n<html>\n  <head>\n    <meta charset=\"utf-8\">\n    <title><%= blog.title %></title>\n    <link rel=\"stylesheet\" href=\"//cdn.bootcss.com/semantic-ui/2.1.8/semantic.min.css\">\n    <link rel=\"stylesheet\" href=\"/css/style.css\">\n    <script src=\"//cdn.bootcss.com/jquery/1.11.3/jquery.min.js\"></script>\n    <script src=\"//cdn.bootcss.com/semantic-ui/2.1.8/semantic.min.js\"></script>\n  </head>\n  <body>\n  <%- include('components/nav') %>\n  <%- include('components/nav-setting') %>\n  <%- include('components/notification') %>"
  },
  {
    "path": "views/post.ejs",
    "content": "<%- include('header') %>\n\n<%- include('components/post-content') %>\n<%- include('components/comments') %>\n\n<%- include('footer') %>\n"
  },
  {
    "path": "views/posts.ejs",
    "content": "<%- include('header') %>\n\n<% posts.forEach(function (post) { %>\n  <%- include('components/post-content', { post: post }) %>\n<% }) %>\n\n<%- include('footer') %>\n"
  },
  {
    "path": "views/signin.ejs",
    "content": "<%- include('header') %>\n\n<div class=\"ui grid\">\n  <div class=\"four wide column\"></div>\n  <div class=\"eight wide column\">\n    <form class=\"ui form segment\" method=\"post\">\n      <div class=\"field required\">\n        <label>用户名</label>\n        <input placeholder=\"用户名\" type=\"text\" name=\"name\">\n      </div>\n      <div class=\"field required\">\n        <label>密码</label>\n        <input placeholder=\"密码\" type=\"password\" name=\"password\">\n      </div>\n      <input type=\"submit\" class=\"ui button fluid\" value=\"登录\">\n    </form>  \n  </div>\n</div>\n\n<%- include('footer') %>\n"
  },
  {
    "path": "views/signup.ejs",
    "content": "<%- include('header') %>\n\n<div class=\"ui grid\">\n  <div class=\"four wide column\"></div>\n  <div class=\"eight wide column\">\n    <form class=\"ui form segment\" method=\"post\" enctype=\"multipart/form-data\">\n      <div class=\"field required\">\n        <label>用户名</label>\n        <input placeholder=\"用户名\" type=\"text\" name=\"name\">\n      </div>\n      <div class=\"field required\">\n        <label>密码</label>\n        <input placeholder=\"密码\" type=\"password\" name=\"password\">\n      </div>\n      <div class=\"field required\">\n        <label>重复密码</label>\n        <input placeholder=\"重复密码\" type=\"password\" name=\"repassword\">\n      </div>\n      <div class=\"field required\">\n        <label>性别</label>\n        <select class=\"ui compact selection dropdown\" name=\"gender\">\n          <option value=\"m\">男</option>\n          <option value=\"f\">女</option>\n          <option value=\"x\">保密</option>\n        </select>\n      </div>\n      <div class=\"field required\">\n        <label>头像</label>\n        <input type=\"file\" name=\"avatar\">\n      </div>\n      <div class=\"field required\">\n        <label>个人简介</label>\n        <textarea name=\"bio\" rows=\"5\"></textarea>\n      </div>\n      <input type=\"submit\" class=\"ui button fluid\" value=\"注册\">\n    </form>\n  </div>\n</div>\n\n<%- include('footer') %>\n"
  }
]