Skip to content

Workflow

techird edited this page Apr 5, 2016 · 9 revisions

项目 4 构建过程

在这个项目中,我们将使用 NodeJS 创建一个 Web 应用,支持用户上传照片以及维护其元数据。具体功能包括:

  1. 支持上传图片到服务器,并且保存到腾讯云 COS 服务中
  2. 保存图片访问信息以及元数据到腾讯云 MongoDB 中
  3. 支持更新图片元数据
  4. 列出所有图片记录,支持搜索以及分页
  5. 支持记录并查询搜索关键字历史
  6. 支持图片及其元数据的删除

下面,我们将一步步带领大家完成这个 Web 应用。

线上运行

目前项目 4 已经在线上运行,地址为 http://project4.qcourse.net

项目对应的 HTTP 请求报文已经分享到 Postman 中,安装了 Postman 的同学可以直接导入或者运行。

https://www.getpostman.com/collections/bf1a387c72503ec19560

Run in Postman

开发过程中,可修改 Postman 的 HTTP 请求地址到自己项目对应的线上地址。

准备

知识

本项目假设开发者已经了解以下基础知识,项目开展过程中这些知识不再讲解:

  • NodeJS
  • Express
  • ES 2015,本项目使用到包括但不限于箭头函数、字符串模板、Generator/Promise

工具

本项目使用到下列开发工具,读者可根据自身情况选择是否使用:

  • SecureCRT - 用于连接到腾讯云服务器,Linux 环境可以直接使用 ssh
  • SecureFX - 用于同步开发环境代码到服务器上
  • PM2 - 一个 NodeJS 应用容器,支持应用自动重启和 Watch 模式

云资源

本项目使用到的云资源包括:

  1. 腾讯云 CVM 1 台,配置 EIP
  2. 腾讯云 COS 服务,创建 bucket 1 个
  3. 腾讯云 CMongo 服务,需要提前申请开通
  4. (可选)腾讯云注册域名一个,用于指向提供的服务。

开工

Step0. 配置 nginx 以及初始化 Node 源码,使线上可访问

此步骤可参照之前的 nginx 配置方式,本项目 Node 应用在 3000 端口运行。项目的启动代码如下:

// server.js
'use strict';

const express = require("express");

function start(port) {
    const app = express();
    
    // tells the client the server can work.
    app.get("/", (request, response) => {
        response.write("It works!");
        response.end();
    });
    app.listen(port || 3000);
}

start();

使用 PM2 运行上面的应用。

pm2 start server.js --watch

此时访问对应的线上地址,应该可以看到服务器输出了一句简单的 "It works!"

Step1. 支持用户上传图片到云服务器

用户上传图片的流程图如下。

image

针对有文件上传的表单,表单的类型是 multipart/form-data。我们需要解析这种类型的表单。当前项目选用了 multer 来作为解析工具。

首先,使用 NPM 安装 multer:

npm init
npm install multer --save

紧接着,我们创建 handle/upload.js 来处理用户的上传请求:

// handle/upload.js
'use strict';

const multer = require("multer");
const printer = require("../lib/printer");

function upload(request, response) {
    // initial a print util
    const print = printer(request, response);
    
    console.log("#0. Start processing upload...");
    // use multer to handle the upload process
    const processUpload = multer({ 
        dest: "/data/uploads/project4.qcourse.net",
        limits: {
            fileSize: 3 * 1024 * 1024, // support max size at 3M
        }
    }).single('image');
    processUpload(request, response, uploadToServer);
    
    function uploadToServer(uploadError) {
        
        if (uploadError) {
            print({ uploadError });
            return;
        }
        
        // check if any file uploaded
        const file = request.file;
        if (!file) {
            print({ msg: "file is required" });
            return;
        }
        console.log("#1. File uploaded to server:");
        console.log(JSON.stringify(file, null, 4));
        
        print({ file });
    }
}

这里使用到了一个本地上传目录,需要提前创建该目录。

mkdir -p /data/uploads/project4.qcourse.net

紧接着,在 server.js 添加对 /upload 的路由处理到 handle/upload.js

// server.js
app.use("/upload", require("./handle/upload"));

同步源码到 CVM 后,PM2 会自动重启应用。此时使用 PM2 查看应用的日志:

pm2 logs

使用 Postman 提交报文,其中 image 字段包含图片,可以看到日志有如下输出:

#0. Start processing upload...
#1. File uploaded to server:
{
    "fieldname": "image",
    "originalname": "qq.png",
    "encoding": "7bit",
    "mimetype": "image/png",
    "destination": "/data/uploads/project4.qcourse.net",
    "filename": "9da14693467cc7e9ccd9170351a4ef65",
    "path": "/data/uploads/project4.qcourse.net/9da14693467cc7e9ccd9170351a4ef65",
    "size": 59482
}

此时,查看上传目录,可以看到文件已经能成功地上传到服务器上。

Step2. 把上传的图片存储到腾讯云 COS 服务中

腾讯云对象存储服务(COS),适用于静态文件的存储。存储的静态文件会提供 CDN 加速,保障访问可靠性和速度。

在上一步中,我们已经把文件存储到了本地临时目录,在这一步,我们将要把它上传到 COS 服务中。

上传到 COS 流程图

腾讯云提供了 COS 服务的 Node SDK,基于 SDK 提供的 cos.upload() 方法,我们封装好项目使用的版本。

// lib/cos.js
'use strict';

const qcloud = require("qcloud_cos");
const conf = qcloud.conf;
const cos = qcloud.cos;

const appId = "10028115";
const secretId = "AKID3vtUR9S5SUFuM0uNW9T9gIpX3gDcZ4fA";
const secretKey = "hH9mBkzjWDwSGcDfzLJPTScCJ7uIWaTl";

conf.setAppInfo(appId, secretId, secretKey);

/**
 * @method upload()
 * 
 * 上传文件到 COS 服务
 * 
 * @param {string} filePath     要上传到 COS 的本地文件路径
 * @param {string} bucketName   指定上传到 COS 的目标 bucket
 * @param {string} destPath     指定上传到 Bucket 下的指定路径
 * @param {string} bizAttr      文件的额外信息
 * @param {Function(result)} callback  上传完成的回掉
 */
exports.upload = function(filePath, bucketName, dstPath, bizAttr, callback) {
    cos.upload(filePath, bucketName, dstPath, bizAttr, callback);
};

注意:要使用腾讯云 SDK,您需要获取应用的 appIdsecretIdsecrectKey,这些信息可以在腾讯云 COS 的控制台上点击「获取 API 密钥」按钮获取。

此时,COS 的上传方法准备完成,我们修改 Step1 中的上传流程,在文件保存到本地后,把本地文件上传到 COS 服务中。

// handle/upload.js
const cos = require('../lib/cos');
function uploadToServer(uploadError) {
    // ...
    // 此时文件已经上传到服务器本地,并且文件信息保存在 file 中
    console.log("#1. File uploaded to server:");
    console.log(JSON.stringify(file, null, 4));
    
    uploadToCos(file);
}

function uploadToCos(file) {
    // upload file to cos
    const uploadPath = `/uploads/${file.filename}${path.extname(file.originalname)}`;
    cos.upload(file.path, 'image', uploadPath, file.filename, (cosResult) => {
        
        console.log("#2. File uploaded to cos:");
        console.log(JSON.stringify(cosResult, null, 4));
        
        // upload to cos error
        if (cosResult.code) {
            print({ cosError: cosResult });
            return;
        }
        
        // clean up local store
        fs.unlink(file.path);
        
        print({ file, cosResult });
    });
}

此时使用 Postman 上传文件,可以看到日志(pm2 logs)输出如下。

#2. File uploaded to cos:
{
    "httpcode": 200,
    "code": 0,
    "message": "成功",
    "data": {
        "access_url": "http://image-10028115.file.myqcloud.com/uploads/0070468356c9dad63945ae5eca5e6914.png",
        "resource_path": "/uploads/0070468356c9dad63945ae5eca5e6914.png",
        "source_url": "http://image-10028115.cos.myqcloud.com/uploads/0070468356c9dad63945ae5eca5e6914.png",
        "url": "http://web.file.myqcloud.com/files/v1/uploads/0070468356c9dad63945ae5eca5e6914.png"
    }
}

可以看到,输出的 cosResult 包含了文件在 COS 服务器的相关信息,该信息将在下一步进一步使用。

同时,在文件上传到 COS 服务之后,上面的代码也把本地的文件删除,避免不必要的存储。

Step3. 把文件访问信息存储到 MongoDB 中

到现在,我们已经可以成功让用户把文件上传到 COS 服务中,紧接着,我们需要把这部分访问信息存储到 MongoDB 上,方便后续为图片添加元数据,并且支持列表、搜索等功能。

腾讯云提供的 MongoDB 服务目前需要申请使用,如果读者还没有体验资格,可以点击这里申请一下。

申请审批之后,打开 MongoDB 控制台,会发现已经拥有一个默认集群。选中集群,点击上面的「修改密码」按钮,设置一下 MongoDB 的访问密码。

修改集群密码

可以使用官方的 mongodb 来访问腾讯云的 MongoDB 集群,下面我们来封装一下 MongoDB 的连接方法。

// mongo.js
'use strict';

const MongoClient = require('mongodb').MongoClient;

const user = 'rwuser';
const password = 'qcourseCase3';
const endpoint = "10.66.125.158:27017";

const url = `mongodb://${user}:${password}@${endpoint}/?authMechanism=MONGODB-CR`;

/**
 * 连接到 mongodb
 * 
*/
function connect(callback) {
    return MongoClient.connect(url, callback);
}

exports.connect = connect;

其中,腾讯云 MongoDB 集群的用户名固定为 rwuser,密码就是刚才设置的密码,endpoint 可以在控制台的集群信息中得到。

下面,我们将使用这里的 connect() 方法连接到 MongoDB 集群,并且插入上一步收集到的文件信息。继续修改上传过程代码。

// handle/upload.js
function uploadToCos(file) {
    // upload file to cos
    
    cos.upload(..., (cosResult) => {
        // ...
        // 到目前文件已经上传到 COS,并且取得访问信息,下一步将其规范化,保存到 MongoDB 中
        
        // prepare file info, insert to mongodb later
        const fileInfo = {
            name: file.originalname,
            size: file.size,
            mime: file.mimetype,
            url: cosResult.data.access_url,
            cos: cosResult.data, // save all cos context
            meta: {}
        };
        
        saveToMongo(fileInfo);
    });
}

function saveToMongo(fileInfo) {
    // connect to mongodb
    mongo.connect((mongoError, db) => {
        if (mongoError) {
            print({ mongoError });
            db.close();
            return;
        }
        
        // insert fileInfo to the `image` connection 
        const collection = db.collection("images");
        collection.insertOne(fileInfo, (insertError, insertResult) => {
            if (insertError) {
                print({ insertError });
                db.close();
                return;
            }
            console.log("#3. Saved to mongodb:");
            console.log(JSON.stringify({ insertResult, fileInfo }, null, 4));
            
            print({ fileInfo });
            db.close();
        });
    });
}

这时候,用户上传图片之后,可以看到日志里输出:

 #3. Saved to mongodb:
{
    "insertResult": {
        "ok": 1,
        "n": 1,
        "lastOp": "6270093661598384129",
        "electionId": "56fbd4fbfc071c79d8a9797e"
    },
    "fileInfo": {
        "name": "qq.png",
        "size": 59482,
        "mime": "image/png",
        "url": "http://image-10028115.file.myqcloud.com/uploads/0070468356c9dad63945ae5eca5e6914.png",
        "cos": {
            "access_url": "http://image-10028115.file.myqcloud.com/uploads/0070468356c9dad63945ae5eca5e6914.png",
            "resource_path": "/uploads/0070468356c9dad63945ae5eca5e6914.png",
            "source_url": "http://image-10028115.cos.myqcloud.com/uploads/0070468356c9dad63945ae5eca5e6914.png",
            "url": "http://web.file.myqcloud.com/files/v1/uploads/0070468356c9dad63945ae5eca5e6914.png"
        },
        "meta": {},
        "_id": "5703d8f7571756c73e7bca33"
    }
}

可以看到,数据已经成功插入到 MongoDB 中。

在使用 MongoDB 的时候,都是先连接得到 db 实例,然后再打开目标 collection,对 collection 进行操作。比如这里使用的是 collection.insertOne() 来插入数据。MongoDB 可以直接操作对象文档,非常适合用于元数据的存储。

关于 MongoDB 的使用方法,建议参考官方 API 文档

Step4. 列出所有上传的文件记录

现在用户每上传一张图片,就会上传到 COS 服务器并且在 MongoDB 以文档的形式保存起来。接下来,我们做一个接口,列出所有用户上传的文件。

首先,在 server.js 添加一个路由处理。

// server.js
app.use("/list", require("./handle/list"));

紧接着,我们创建 handle/list.js 来处理这个路由。

// handle/list.js
'use strict';

const async = require("co");
const printer = require("../lib/printer");
const mongo = require("../lib/mongo");

const list = (request, response) => async (function * () {
    const print = printer(request, response);
    const params = request.query;
    
    let db;
    
    try {
        db = yield mongo.connect();
    } catch (mongoError) {
        print(mongoError);
        return;
    }
    
    try {
        const imageCollection = db.collection("images");
        let query = imageCollection.find();
        const list = yield query.toArray();
        print({ list });
    } catch (error) {
        console.log(error);
        print({ error });
    } finally {
        db.close();
    }
});

module.exports = list;

上述代码使用 db.collection("images").find() 方法得到了图片文档集合的游标,然后再调用 toArray() 方法将文档列出到数组。使用 Postman 执行 /list 请求后,可以收到如下响应。

{
  "list": [
    {
      "_id": "5702287c425194b13df1cacf",
      "name": "qq.png",
      "size": 59482,
      "mime": "image/png",
      "url": "http://image-10028115.file.myqcloud.com/uploads/1ec90386994e371b1f51809be59324a9.png",
      "cos": {
        "access_url": "http://image-10028115.file.myqcloud.com/uploads/1ec90386994e371b1f51809be59324a9.png",
        "resource_path": "/uploads/1ec90386994e371b1f51809be59324a9.png",
        "source_url": "http://image-10028115.cos.myqcloud.com/uploads/1ec90386994e371b1f51809be59324a9.png",
        "url": "http://web.file.myqcloud.com/files/v1/uploads/1ec90386994e371b1f51809be59324a9.png"
      },
      "meta": {}
    }
  ]
}

注意到上述代码使用了 ES2015 Generator + Promise 来处理异步代码,会比写 callback 的形式优雅很多。简单介绍就是 ES2015 的 Generator 可以使函数执行到特定的位置「暂停」( yield 关键字上),等到合适的时候再「重启」(generator.next())。

利用这个特性,可以编写一个 Runtime,让代码在 yield 一个 Promise 的时候暂停,又在 Promise onfulfill 的时候继续。这样代码看起来就像是执行到一半的时候在「等待」一个数据或者响应。在这个项目中,选用了 co 作为 Generator Promise Runtime。

Node 4.x 的版本已经支持 Generator,所以在 Node 环境下,可以大胆使用这些新特性。

更多资料,可以参考这篇文章

Step5. 支持图片元数据更新

当客户端可以列出图片数据的时候,就可以根据图片记录的 ID 来更新元数据。同样是通过 MongoDB 进行操作。

我们客户端通过 POST JSON 的形式提交要修改的图片 ID 以及元数据,如:

{
    "id": "5702287c425194b13df1cacf",
    "meta": {
        "author": "techird",
        "labels": [
            "qq",
            "icon"
        ],
        "alt": "QQ Icon"
    }
}

那么 Express 接收到这个数据的时候,是不知道怎么去解析的。这时候我们需要添加 body-parser,来解析 JSON 类型的 Request Body。

npm install body-parser --save

修改 server.js,为其添加 body-parser 中间件。

// server.js
const bodyParser = require("body-parser");

// 注意放在所有路由前面
app.use(bodyParser.json());

可以解析 JSON 类型的报文了,我们添加一个路由来处理元数据的更改。

// server.js
app.use("/meta", require("./handle/meta"));

然后,创建 handle/meta.js 来处理该请求。

// handle/meta.js
'use strict';

const async = require("co");
const printer = require("../lib/printer");
const mongo = require("../lib/mongo");
const ObjectId = require("mongodb").ObjectId;

/**
 * 更新/添加元数据
 */
const meta = (request, response) => async (function * () {
    const print = printer(request, response);
    const body = request.body;
    const id = body.id;
    const meta = body.meta;
    
    // parameter checks
    if (!id) {
        print({ error: "Specific `id` to tell the server which image meta to update" });
        return;
    }
    if (!meta) {
        print({ error: "No meta specific" });
        return;
    }
    
    // connect to mongo db
    let db;
    try {
        db = yield mongo.connect();
    } catch (mongoError) {
        print({ mongoError });
        return;
    }
    
    // find and update
    try {
        const collection = db.collection("images");
        
        const query = { _id: ObjectId(id) };
        const update = { $set: { meta } };
        const result = yield collection.findOneAndUpdate(query, update);
        
        print({ result });
        
    } catch (error) {
        print({ error });
    } finally {
        db.close();
    }
});

module.exports = meta;

这里核心的代码只有几行:

const collection = db.collection("images");

const query = { _id: ObjectId(id) };
const update = { $set: { meta } };
const result = yield collection.findOneAndUpdate(query, update);

首先,创建一个用于定位要更新的文档的查询,然后定义更新的内容。使用 collection.findOneAndUpdate() 方法即可完成更新操作。需要注意的是,针对自动生成的 _id 字段,需要使用 ObjectId 来构造查询条件。

Step6. 添加分页支持和搜索到列表上

如果用户上传的文件很多,那么列出全部数据将会耗费大量的时间和资源。这种情况下,我们一般会提供分页接口来支持数据分页。

分页接口设计里面,一般包含 pageIndex 表示请求的页面以及 pageSize 表示请求每页的数据量。在本例,我们直接使用用户从 QueryString 传过来的这两个参数。其中 pageIndex 从 0 开始索引。

分页系统的设计里面,还要求返回给客户端总记录数,这个数据将用户客户端计算最大页码。

Step7. 添加搜索历史记录

Step8. 支持文件及元数据删除