Express+MongoDB搭建图片分享社区二

811 阅读10分钟

前言

在上一篇文章Express+MongoDB搭建图片分享社区一中我们实现了路由、页面和数据渲染。

本篇文章我们要实现图片上传、接入mongoDB数据库、评论、点赞、删除等功能。

图片上传功能

在根目录创建public/upload文件夹,用于存放用户上传的图片, 然后安装 multer 中间件用于处理文件上传:

npm i multer

在 server/routes.js 模块中,我们初始化 multer 中间件,然后将其添加到上传图片的路由中(即 POST /images), 代码如下所示:

const express = require('express');
const multer = require('multer');
const path = require('path');

const router = express.Router();
const upload = multer({ dest: path.join(__dirname, '../public/upload/temp') });
const home = require('../controllers/home');
const image = require('../controllers/image');

module.exports = function(app) {
  router.get('/', home.index);
  router.get('/images/:image_id', image.index);
  router.post('/images', upload.single('file'), image.create);
  router.post('/images/:image_id/like', image.like);
  router.post('/images/:image_id/comment', image.comment);
  app.use(router);
};

上述代码有两点需要讲解:

  • 第 6 行,在初始化 upload 中间件时,传入 dest 选项指定保存上传文件的路径,这里我们选择在 public/upload 目录中再创建一个 temp 目录用于临时保存上传到的图片。
  • 第 14 行,router.post 除第一个参数为 URL,后面可以跟任意多个中间件,这里我们将上传文件的中间件添加到 image.create 控制器的前面,确保先处理用户上传的文件。这里 upload.single('file') 表示只处理单个上传文件,并且字段名为 file,在后续中间件中就可以通过 req.file 进行获取。

注意

在dest选项这里图灵社区的路径是public/upload/temp,但是我在操作过程中发现这个路径不是项目根目录下,而是在当前server文件夹下,所以这里的路径要用../public/upload/temp

关于 multer 的详细用法,可以参考其文档

controller/image.js

const fs = require('fs');
const path = require('path');

module.exports = {
  index: function(req, res) {
    const viewModel = {
      image: {
        uniqueId: 1,
        title: '示例图片1',
        description: '这是张测试图片',
        filename: 'sample1.jpg',
        views: 0,
        likes: 0,
        timestamp: Date.now(),
      },
      comments: [
        {
          image_id: 1,
          email: 'test@testing.com',
          name: 'Test Tester',
          comment: 'Test 1',
          timestamp: Date.now(),
        },
        {
          image_id: 1,
          email: 'test@testing.com',
          name: 'Test Tester',
          comment: 'Test 2',
          timestamp: Date.now(),
        },
      ],
    };
    res.render('image', viewModel);
  },
  create: function(req, res) {
    var tempPath = req.file.path;
    var imgUrl = req.file.filename;
    var ext = path.extname(req.file.originalname).toLowerCase();
    var targetPath = path.resolve('./public/upload/' + imgUrl + ext);

    if (ext === '.png' || ext === '.jpg' || ext === '.jpeg' || ext === '.gif') {
      fs.rename(tempPath, targetPath, function(err) {
        if (err) throw err;
        res.redirect('/images/' + imgUrl);
      });
    } else {
      fs.unlink(tempPath, function(err) {
        if (err) throw err;
        res.json(500, { error: '只允许上传图片文件.' });
      });
    }
  },
  like: function(req, res) {
    res.send('The image:like POST controller');
  },
  comment: function(req, res) {
    res.send('The image:comment POST controller');
  },
};

req.file 是一个 Multer 文件对象,包括 path(上传到服务器的路径)、filename(服务器存储的文件名)和 originalname(文件初始名,即保存在客户端的文件名)等有用的属性。我截取了一张输出 req.file 所有字段的图片如下: req.file图

这里我们通过简单的后缀匹配来判断用户上传的是否为图片,如果是,则从临时目录 tempPath 存放到上传目录 targetPath 中,否则直接删除。上传成功后,通过 res.redirect 将页面重定向到刚刚上传的图片的详情页面。

接入MongoDB数据库

首先安装MongoDB:

MongoDB官网 菜鸟教程MongoDB安装

安装之后新开一个终端根据不同平台的对应方法打开数据库。

然后安装Mongoose:

npm i mongoose

Mongoose 是 MongoDB 最流行的 ODM(Object Document Mapping,对象文档映射),使用起来要比底层的 MongoDB Node 驱动更方便。

我们首先实现图片有关的数据模型。创建 models 目录,在其中添加 image.js 模块,并添加实现 ImageSchema 的代码: model/image.js

const mongoose = require('mongoose');
const path = require('path');

const Schema = mongoose.Schema;

const ImageSchema = new Schema({
  title: { type: String },
  description: { type: String },
  filename: { type: String },
  views: { type: Number, default: 0 },
  likes: { type: Number, default: 0 },
  timestamp: { type: Date, default: Date.now },
});

ImageSchema.virtual('uniqueId').get(function() {
  return this.filename.replace(path.extname(this.filename), '');
});

module.exports = mongoose.model('Image', ImageSchema);

我们在第 6 行到第 13 行定义了一个 Schema,即数据对象的模式,描述了这个模型的所有字段及相应的属性。这里我们为 ImageSchema 定义了六个字段,每个字段都有其类型(必须),views、likes 和 timestamp 还有相应的默认值(可选)。除了普通字段外,我们还定义了虚字段uniqueId。虚字段(virtuals)和普通字段的最大区别是不会保存到数据库中,而是在每次查询时临时计算,通常用于对普通字段进行格式调整或组合。在 Schema 定义完成后,我们将其编译为名为 Image 的模型并导出,方便在控制器中进行使用。

接着我们在 home 控制器中调用 ImageModel 来从数据库中获取全部图片: controller/home.js

const ImageModel = require('../models/image');

module.exports = {
  index: function(req, res) {
    const viewModel = { images: [] };

    ImageModel.find({}, {}, { sort: { timestamp: -1 } }, function(err, images) {
      if (err) throw err;
      viewModel.images = images;
      res.render('index', viewModel);
    });
  },
};

在第 39 行中,我们用 find 方法查询图片,所有的查询方法可参考 Mongoose 中文文档。find 是查询多条数据记录的通用方法,其四个参数如下:

  • filter:过滤器,是一个 JavaScript 对象,例如 { name: 'john' } 则限定返回所有名字为 john 的记录,这里我们用 {} 表示查询所有记录;
  • projection(可选):查询所返回的字段,可以是对象或字符串,我们用 {} 表示返回所有字段;
  • options(可选):查询操作的选项,用来指定查询操作的一些参数,比如我们用 sort 选项对返回结果进行排序(这里按照发布时间 timestamp 进行倒序排列,即把最新发布的放在最前面);
  • callback:回调函数,用于添加在查询完毕时的业务逻辑;

进一步,我们在 image 控制器中添加数据库操作的代码: controller/image.js

const fs = require('fs');
const path = require('path');
const ImageModel = require('../models/image');

module.exports = {
  index: function(req, res) {
    const viewModel = { image: {}, comments: [] };

    ImageModel.findOne({ filename: { $regex: req.params.image_id } }, function(
      err,
      image,
    ) {
      if (err) throw err;
      if (image) {
        // 增加该图片的访问量
        image.views += 1;
        viewModel.image = image;
        image.save();
        res.render('image', viewModel);
      } else {
        res.redirect('/');
      }
    });
  },
  create: function(req, res) {
    var tempPath = req.file.path;
    var imgUrl = req.file.filename;
    var ext = path.extname(req.file.originalname).toLowerCase();
    var targetPath = path.resolve('./public/upload/' + imgUrl + ext);

    if (ext === '.png' || ext === '.jpg' || ext === '.jpeg' || ext === '.gif') {
      fs.rename(tempPath, targetPath, function(err) {
        if (err) throw err;
        const newImg = new ImageModel({
          title: req.body.title,
          description: req.body.description,
          filename: imgUrl + ext,
        });
        newImg.save(function(err, image) {
          if (err) throw err;
          res.redirect('/images/' + image.uniqueId);
        });
      });
    } else {
      fs.unlink(tempPath, function(err) {
        if (err) throw err;
        res.json(500, { error: '只允许上传图片文件.' });
      });
    }
  },
  like: function(req, res) {
    res.send('The image:like POST controller');
  },
  comment: function(req, res) {
    res.send('The image:comment POST controller');
  },
};

在 image.index 和 image.create 两个控制器中,我们分别进行了单条数据记录的查询和插入。findOne 与之前的 find 参数格式完全一致,只不过仅返回一条数据。在插入新数据时,先创建一个 ImageModel 实例,然后再调用 save 方法进行保存即可。

最后,我们需要在服务器刚刚运行时就连接好数据库,因此在 server.js 中添加如下代码: server.js


const express = require('express');
const mongoose = require('mongoose');
const configure = require('./server/configure');

app = express();
app = configure(app);

// 建立数据库连接
mongoose.connect('mongodb://localhost/instagrammy');
mongoose.connection.on('open', function() {
  console.log('Mongoose connected.');
});

app.set('port', process.env.PORT || 3000);

app.listen(app.get('port'), function() {
  console.log(`Server is running on http://localhost:${app.get('port')}`);
});

运行过程中的问题

这里我们运行服务器的话会有如下的输出信息: 输出1

解决方法: mongoose连接数据库时除了url参数外增加1个参数: {useNewUrlParser:true,useUnifiedTopology: true} server.js

const express = require('express');
const mongoose = require('mongoose');
const configure = require('./server/configure');

app = express();
app = configure(app);

// 建立数据库连接
mongoose.connect('mongodb://localhost/instagrammy', {useNewUrlParser:true,useUnifiedTopology:true });
mongoose.connection.on('open', function() {
    console.log('Mongoose connected.');
})

app.set('port', process.env.PORT || 3000);

app.listen(app.get('port'), function() {
    console.log(`Server is running on http://localhost:${app.get('port')}`)
})

然后我们重新运行服务器,打开localhost:3000,我们会发现console有报错信息: 输出2

我们走一下上传图片的过程,上传跳转到图片详情会发现页面是这个样子: 输出3

在服务器的日志上发现有如下所示信息: 输出4

stackoverflow有这个问题的 相关讨论,有兴趣的可以去看一下

解决方法:

安装依赖:

npm i @handlebars/allow-prototype-access

然后修改server/configure.js的内容如下所示:

const path = require('path');
const Handlebars = require('handlebars');
const exphbs = require('express-handlebars');
const express = require('express');
const bodyParser = require('body-parser');
const cookieParser = require('cookie-parser');
const morgan = require('morgan');
const methodOverride = require('method-override');
const errorHandler = require('errorhandler');
const moment = require('moment');

const { allowInsecurePrototypeAccess } = require('@handlebars/allow-prototype-access');

const routes = require('./routes');

module.exports = function(app) {
    // 定义 moment 全局语言 
    moment.locale('zh-cn');
    app.engine('handlebars', exphbs.create({
        helpers: {
            timeago: function(timestamp) {
                return moment(timestamp).startOf('minute').fromNow();
            }
        },
        handlebars: allowInsecurePrototypeAccess(Handlebars)
    }).engine)
    app.set('view engine', 'handlebars');

    app.use(morgan('dev'));
    app.use(bodyParser.urlencoded({ extended: true }));
    app.use(bodyParser.json());
    app.use(methodOverride());
    app.use(cookieParser('secret-value'));
    app.use('/public/', express.static(path.join(__dirname, '../public')));

    if (app.get('env') === 'development') {
        app.use(errorHandler())
    }

    routes(app);
    return app;
}

现在我们来重新运行服务器,尝试上传图片,可以发现不仅能上传成功,还可以在首页看到新添加的图片了!

实现评论功能

这一步我们来实现网站的评论功能。按照 MVC 模式,我们将依次实现评论的模型(M)、视图(V)和控制器(C)。 首先,仿照 models/image.js,我们实现评论的数据模型: models/comment.js

const mongoose = require('mongoose');

const Schema = mongoose.Schema;
const ObjectId = Schema.ObjectId;

const CommentSchema = new Schema({
  image_id: { type: ObjectId },
  email: { type: String },
  name: { type: String },
  gravatar: { type: String },
  comment: { type: String },
  timestamp: { type: Date, default: Date.now },
});

CommentSchema.virtual('image')
  .set(function(image) {
    this._image = image;
  })
  .get(function() {
    return this._image;
  });

module.exports = mongoose.model('Comment', CommentSchema);

CommentSchema 有两个字段需要补充说明一下:

  • image_id:由于图片和评论是一对多的关系(即一张图片包括多个评论),因此我们需要在记录每个评论所属的图片,即通过 image_id 字段进行记录;
  • gravatar:用 MD5 对电子邮箱加密后得到的字符串,用于访问 Gravatar 服务。Gravatar 提供了跨网站的头像服务,如果你在集成了 Gravatar 服务的网站通过邮箱注册并上传了头像,那么别的网站也可以通过 Gravatar 访问你的头像。这里请通过 npm install md5 安装 MD5 加密的包。

我们对评论有关的界面代码进行细微的调整,将提交按钮的 type 从 button 改为 submit: views/image.handlebars 评论

最后是评论有关的 controller 代码。包括在 image.comment 中实现创建评论,以及在 image.index 中实现对单张图片所有评论的查询: controllers/image.js

const fs = require('fs');
const path = require('path');
const md5 = require('md5');
const ImageModel = require('../models/image');
const CommentModel = require('../models/comment');

module.exports = {
  index: function(req, res) {
    const viewModel = { image: {}, comments: [] };

    ImageModel.findOne({ filename: { $regex: req.params.image_id } }, function(
      err,
      image,
    ) {
      if (err) throw err;
      if (image) {
        // 增加该图片的访问量
        image.views += 1;
        viewModel.image = image;
        image.save();

        CommentModel.find(
          { image_id: image._id },
          {},
          { sort: { timestamp: 1 } },
          function(err, comments) {
            if (err) throw err;
            viewModel.comments = comments;
            res.render('image', viewModel);
          },
        );
      } else {
        res.redirect('/');
      }
    });
  },
  create: function(req, res) {
    var tempPath = req.file.path;
    var imgUrl = req.file.filename;
    var ext = path.extname(req.file.originalname).toLowerCase();
    var targetPath = path.resolve('./public/upload/' + imgUrl + ext);

    if (ext === '.png' || ext === '.jpg' || ext === '.jpeg' || ext === '.gif') {
      fs.rename(tempPath, targetPath, function(err) {
        if (err) throw err;
        const newImg = new ImageModel({
          title: req.body.title,
          description: req.body.description,
          filename: imgUrl + ext,
        });
        newImg.save(function(err, image) {
          if (err) throw err;
          res.redirect('/images/' + image.uniqueId);
        });
      });
    } else {
      fs.unlink(tempPath, function(err) {
        if (err) throw err;
        res.json(500, { error: '只允许上传图片文件.' });
      });
    }
  },
  like: function(req, res) {
    res.send('The image:like POST controller');
  },
  comment: function(req, res) {
    ImageModel.findOne({ filename: { $regex: req.params.image_id } }, function(
      err,
      image,
    ) {
      if (!err && image) {
        const newComment = new CommentModel(req.body);
        newComment.gravatar = md5(newComment.email);
        newComment.image_id = image._id;
        newComment.save(function(err, comment) {
          if (err) throw err;
          res.redirect('/images/' + image.uniqueId + '#' + comment._id);
        });
      } else {
        res.redirect('/');
      }
    });
  },
};

查询与创建评论的代码和之前操作图片的代码大部分都是一致的,最大的差别在于查询时需要根据所属的图片 ID,创建时需要记录图片的 ID。这里我们约定使用 MongoDB 为每一条数据默认创建的 _id 字段。

接下来我们重启服务器,然后找一张图片点进详情测试一下评论功能 评论

实现图片的点赞和删除

这一步我们来实现图片的点赞和删除。 首先在控制器中添加点赞和删除的代码: controller/image.js

const fs = require('fs');
const path = require('path');
const md5 = require('md5');
const ImageModel = require('../models/image');
const CommentModel = require('../models/comment');

module.exports = {
  index: function(req, res) {
    const viewModel = { image: {}, comments: [] };

    ImageModel.findOne({ filename: { $regex: req.params.image_id } }, function(
      err,
      image,
    ) {
      if (err) throw err;
      if (image) {
        // 增加该图片的访问量
        image.views += 1;
        viewModel.image = image;
        image.save();

        CommentModel.find(
          { image_id: image._id },
          {},
          { sort: { timestamp: 1 } },
          function(err, comments) {
            if (err) throw err;
            viewModel.comments = comments;
            res.render('image', viewModel);
          },
        );
      } else {
        res.redirect('/');
      }
    });
  },
  create: function(req, res) {
    var tempPath = req.file.path;
    var imgUrl = req.file.filename;
    var ext = path.extname(req.file.originalname).toLowerCase();
    var targetPath = path.resolve('./public/upload/' + imgUrl + ext);

    if (ext === '.png' || ext === '.jpg' || ext === '.jpeg' || ext === '.gif') {
      fs.rename(tempPath, targetPath, function(err) {
        if (err) throw err;
        const newImg = new ImageModel({
          title: req.body.title,
          description: req.body.description,
          filename: imgUrl + ext,
        });
        newImg.save(function(err, image) {
          if (err) throw err;
          res.redirect('/images/' + image.uniqueId);
        });
      });
    } else {
      fs.unlink(tempPath, function(err) {
        if (err) throw err;
        res.json(500, { error: '只允许上传图片文件.' });
      });
    }
  },
  like: function(req, res) {
    ImageModel.findOne({ filename: { $regex: req.params.image_id } }, function(
      err,
      image,
    ) {
      if (!err && image) {
        image.likes += 1;
        image.save(function(err) {
          if (err) res.json(err);
          else res.json({ likes: image.likes });
        });
      }
    });
  },
  remove: function(req, res) {
    ImageModel.findOne({ filename: { $regex: req.params.image_id } }, function(
      err,
      image,
    ) {
      if (err) throw err;
      fs.unlink(path.resolve('./public/upload/' + image.filename), function(
        err,
      ) {
        if (err) throw err;
        CommentModel.remove({ image_id: image._id }, function(err) {
          image.remove(function(err) {
            if (!err) {
              res.json(true);
            } else {
              res.json(false);
            }
          });
        });
      });
    });
  },
  comment: function(req, res) {
    ImageModel.findOne({ filename: { $regex: req.params.image_id } }, function(
      err,
      image,
    ) {
      if (!err && image) {
        const newComment = new CommentModel(req.body);
        newComment.gravatar = md5(newComment.email);
        newComment.image_id = image._id;
        newComment.save(function(err, comment) {
          if (err) throw err;
          res.redirect('/images/' + image.uniqueId + '#' + comment._id);
        });
      } else {
        res.redirect('/');
      }
    });
  },
};

在两个控制器中,我们都按照查询 -> 修改 -> 保存的流程进行操作。不过在删除图片中,我们不仅先删除上传图片,再删除了此图片所有的评论模型,最后再删除数据库中的图片模型,这一切通过 Model.remove 方法都可以轻松实现。remove 的使用方法与之前的 find 几乎一模一样,只不过 find 会返回符合条件的结果,而 remove 则会直接将符合条件的记录从数据库中删除。

我们在路由模块 server/routes.js 中添加刚刚写好的 image.remove 控制器:

const express = require('express');
const multer = require('multer');
const path = require('path');

const router = express.Router();
const upload = multer({ dest: path.join(__dirname, 'public/upload/temp') });
const home = require('../controllers/home');
const image = require('../controllers/image');

module.exports = function(app) {
  router.get('/', home.index);
  router.get('/images/:image_id', image.index);
  router.post('/images', upload.single('file'), image.create);
  router.post('/images/:image_id/like', image.like);
  router.post('/images/:image_id/comment', image.comment);
  router.delete('/images/:image_id', image.remove);
  app.use(router);
};

我们来重新运行服务器,然后试一下点赞和删除发现并没有任何作用,因为上面的只是服务端的逻辑,前端页面显示并没有对应的逻辑,所以我们用jQuery来实现前端的点赞和删除请求。 views/layouts/main.handlebars

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <title>Instagrammy</title>
  <link href="http://netdna.bootstrapcdn.com/bootstrap/3.1.1/css/bootstrap.min.css" rel="stylesheet">
  <link href="http://netdna.bootstrapcdn.com/font-awesome/4.0.3/css/font-awesome.min.css" rel="stylesheet">
</head>

<body>
  <div class="page-header">
    <div class="container">
      <div class="col-md-6">
        <h1><a href="/">Instagrammy</a></h1>
      </div>
    </div>
  </div>

  <div class="container">
    <div class="row">
      <div class="col-sm-8">{{{body}}}</div>
      <div class="col-sm-4">
        {{> stats this}}
        {{> popular this}}
        {{> comments this}}
      </div>
    </div>
  </div>
</body>

<script src="https://cdn.bootcss.com/jquery/3.4.1/jquery.min.js"></script>
<script>
  $(function () {
    // to do...
  });
  $('#btn-like').on('click', function (event) {
    event.preventDefault();
    var imgId = $(this).data('id');
    $.post('/images/' + imgId + '/like').done(function (data) {
      $('.likes-count').text(data.likes);
    });
  });
  $('#btn-delete').on('click', function (event) {
    event.preventDefault();
    var $this = $(this);
    var remove = confirm('确定要删除这张图片吗?');
    if (remove) {
      var imgId = $(this).data('id');
      $
        .ajax({
          url: '/images/' + imgId,
          type: 'DELETE'
        })
        .done(function (result) {
          if (result) {
            $this.removeClass('btn-danger').addClass('btn-success');
            $this.find('i').removeClass('fa-times').addClass('fa-check');
            $this.append('<span> 已删除!</span>');
            alert('删除成功');
            window.location.href=document.referrer;
          }
        });
    }
  });
</script>
</html>

现在我们重新运行服务器,然后打开localhost:3000来体验下点赞和删除功能,可以看到点赞和删除已经实现了,下一步我们将完善侧边栏的数据同步更新。

完善用户界面

这一步我们将实现侧边栏所有数据的同步更新。 首先先创建helpers目录用于存放侧边栏数据获取的相关代码。然后分析一下数据同步逻辑(例如统计数据),我们发现要进行的查询非常多:图片总数、评论总数、图片所有的访问量、图片所有的点赞数。如果按照普通的写法,我们也许会这样写:

queryA(function(err, resultsA) {
  queryB(function(err, resultsB) {
    queryC(function(err, resultsC) {
      queryD(function(err, resultsD) {
        // some code ...
      }
    }
  }
}

这样的代码不仅十分丑陋,难以维护(即大家常说的 “回调地狱”),而且性能也十分糟糕 —— 所有查询都是链式执行。但其实所有的查询都是相互独立的,完全可以并发进行,那我们应该怎么写呢?

答案就是 async 库。async 是在 ECMAScript 6 的 Promise 体系出现之前最流行的异步组件库,凭借其强大的性能、丰富且设计良好的接口成为 Node 和前端开发中解决异步的最佳选择之一。这里我们也用 async 来解决并发获取数据的问题。安装 async 包:

npm i async

然后创建helpers/stats.js用户获取网站统计数据:

const async = require('async');
const ImageModel = require('../models/image');
const CommentModel = require('../models/comment');

module.exports = function(callback) {
  async.parallel(
    [
      function(next) {
        // 统计图片总数
        ImageModel.countDocuments({}, next);
      },
      function(next) {
        // 统计评论总数
        CommentModel.countDocuments({}, next);
      },
      function(next) {
        // 对图片所有访问量求和
        ImageModel.aggregate(
          [
            {
              $group: {
                _id: '1',
                viewsTotal: { $sum: '$views' },
              },
            },
          ],
          function(err, result) {
            if (err) {
              return next(err);
            }
            var viewsTotal = 0;
            if (result.length > 0) {
              viewsTotal += result[0].viewsTotal;
            }
            next(null, viewsTotal);
          },
        );
      },
      function(next) {
        // 对所有点赞数求和
        ImageModel.aggregate(
          [
            {
              $group: {
                _id: '1',
                likesTotal: { $sum: '$likes' },
              },
            },
          ],
          function(err, result) {
            if (err) {
              return next(err);
            }
            var likesTotal = 0;
            if (result.length > 0) {
              likesTotal += result[0].likesTotal;
            }
            next(null, likesTotal);
          },
        );
      },
    ],
    function(err, results) {
      callback(null, {
        images: results[0],
        comments: results[1],
        views: results[2],
        likes: results[3],
      });
    },
  );
};

这里我们用到了 async.parallel 接口,它接受两个参数:

  • tasks:一个函数数组,每个函数对应一个异步任务(所有任务将并发执行),并且接受一个回调函数用于返回任务执行的结果;
  • callback:整个任务组的回调函数,可以获取所有异步任务执行完成后的所有结果。

我们将四个数据查询任务包装成四个函数作为 async.parallel 的第一个参数,在最后的 callback 中返回所有查询结果。非常简洁、优雅。

接下来实现侧边栏中的最新图片模块,一个简单的数据库查询即可: helpers/images.js

const ImageModel = require('../models/image');

module.exports = {
    popular: function(callback) {
        ImageModel.find({}, {}, { limit: 9, sort: { likes: 01 } }, function(err, images) {
            if (err) return callback(err);
            callback(null, images);
        })
    }
}

然后是创建获取最新评论的代码。不过简单地查询评论模型是不够的,我们还需要获取到每个评论对应的图片,这时候用 async.each 函数对一个数组中所有对象进行异步操作最为合适不过。整个模块的代码如下:

const async = require('async');
const ImageModel = require('../models/image');
const CommentModel = require('../models/comment');

module.exports = {
    newest: function(callback) {
        CommentModel.find({}, {}, { limit: 5, sort: { timestamp: -1 } }, function(err, comments) {
            if (err) return callback(err);
            var attachImage = function(comment, next) {
                ImageModel.findOne({ _id: comment.image_id }, function(err, image) {
                    if (err) throw err;
                    comment.image = image;
                    next(err);
                })
            }
            async.each(comments, attachImage, function(err) {
                if (err) throw err;
                callback(err, comments);
            })
        })
    }
}

async.each 函数接受的三个参数如下:

  • collection:用于接收异步操作的集合,这里是评论集;
  • iteratee:异步操作函数,这里是 attachImage 函数;
  • callback:全部操作执行完成的回调函数;

然后将前面三个 helper 函数放到一起,创建一个 sidebar 模块,并发获取三个模块的数据。这里我们还是用 async.parallel 函数,因为三个模块本质上也是异步查询: helpers/sidebars.js

const async = require('async');
const Stats = require('./stats');
const Images = require('./images');
const Comments = require('./comments');

module.exports = function(viewModel, callback) {
    async.parallel(
        [
            function(next) {
                Stats(next);
            },
            function(next) {
                Images.popular(next);
            },
            function(next) {
                Comments.newest(next);
            }
        ],
        function(err, results) {
            viewModel.sidebar = {
                stats: results[0],
                popular: results[1],
                comments: results[2]
            }
            callback(viewModel);
        }
    )
}

最后将sidebar 模块用到 home 和 image 控制器中: controllers/home.js

const sidebar = require('../helpers/sidebar');
const ImageModel = require('../models/image');

module.exports = {
  index: function(req, res) {
    const viewModel = { images: [] };

    ImageModel.find({}, {}, { sort: { timestamp: -1 } }, function(err, images) {
      if (err) throw err;
      viewModel.images = images;
      sidebar(viewModel, function(viewModel) {
        res.render('index', viewModel);
      });
    });
  },
};

controllers/image.js

const fs = require('fs');
const path = require('path');
const md5 = require('md5');
const sidebar = require('../helpers/sidebar');
const ImageModel = require('../models/image');
const CommentModel = require('../models/comment');

module.exports = {
    index: function(req, res) {
        const viewModel = { image: {}, comments: [] };
        ImageModel.findOne({ filename: { $regex: req.params.image_id } }, function(err, image) {
          if (err) throw err;
          if (image) {
            // 增加该图片的访问量
            image.views += 1;
            viewModel.image = image;
            image.save();

            CommentModel.find(
              { image_id: image._id },
              {},
              { sort: { timestamp: 1 } },
              function(err, comments) {
                if (err) throw err;
                viewModel.comments = comments;
                sidebar(viewModel, function(viewModel) {
                  res.render('image', viewModel);
                })
              }
            )
          } else {
            res.redirect('/');
          }
        })
    },
    create: function(req, res) {
        var tempPath = req.file.path;
        var imgUrl = req.file.filename;
        var ext = path.extname(req.file.originalname).toLowerCase();
        var targetPath = path.resolve('./public/upload/' + imgUrl + ext);

        if (ext === '.png' || ext === '.jpg' || ext === '.jpeg' || ext === '.gif') {
          fs.rename(tempPath, targetPath, function(err) {
            if (err) throw err;
            const newImg = new ImageModel({
              title: req.body.title,
              description: req.body.description,
              filename: imgUrl + ext
            })
            newImg.save(function(err, image) {
              if (err) throw err;
              res.redirect('/images/' + image.uniqueId);
            })
          })
        } else {
          fs.unlink(tempPath, function(err) {
            if (err) throw err;
            res.json(500, { error: '只允许上传图片文件.' });
          })
        }
    },
    like: function(req, res) {
        ImageModel.findOne({ filename: { $regex: req.params.image_id } }, function(err, image) {
          if (!err && image) {
            image.likes += 1;
            image.save(function(err) {
              if (err) res.json(err);
              else res.json({ likes: image.likes })
            })
          }
        })
    },
    remove: function(req, res) {
      ImageModel.findOne({ filename: { $regex: req.params.image_id } }, function(err, image) {
        if (err) throw err;
        fs.unlink(path.resolve('./public/upload/' + image.filename), function(err) {
          if (err) throw err;
          CommentModel.remove({ image_id: image._id }, function(err) {
            image.remove(function(err) {
              if (!err) {
                res.json(true);
              } else {
                Response.json(false);
              }
            })
          })
        })
      })
    },
    comment: function(req, res) {
        ImageModel.findOne({ filename: { $regex: req.params.image_id } }, function(err, image) {
          if (!err && image) {
            const newComment = new CommentModel(req.body);
            newComment.gravatar = md5(newComment.email);
            newComment.image_id = image._id;
            newComment.save(function(err, comment) {
              if (err) throw err;
              res.redirect('/images/' + image.uniqueId + '#' + comment._id);
            })
          } else {
            res.redirect('/')
          }
        })
    }
}

现在我们来重新运行服务器看下侧边栏的数据有没有同步更新吧!

到这里我们今天要实现的功能已经全部实现了,你可以在这个的基础上自己再拓展。