使用 Koa2 开发小结

RSS 订阅器项目是我最近花时间比较多的一个项目了. 在这个项目中我使用了大量的新技术, 很多技术都是我第一次使用. 后端是基于 Koa2 和 Mongoose 的 RESTful API.

在这个项目开展前, 我已经有半年多没接触后端了. 上一次后端还是用 PHP 以及 Laravel 框架开发的 LNMP 架构. 在动工前, 我也没有正式的使用过 Node 以及其部署, 对于 Koa2 的 async await 的异步书写方式也只是久仰大名而已.

这篇博客主要想说一说自己在使用 Node.js 和 Koa2 开发后端过程中的一些总结和收获.

中间件

Koa2 本身是一个非常轻的框架, 我们需要使用大量的中间件去完善它, 例如 koa-bodyparser , koa-etag, koa-router, koa-sslify 等等.

同时, 肯定免不了自己写中间件, 例如我自己就写了 7 个中间件, 分别是处理缓存, 处理 cookies, 强制 www, 配合前端 HTML5Mode, 错误处理, UA 判断, JWT 和 XSRF 处理.

module.exports = function () {
    return async(ctx, next) => {
        if (/^\/(mark|square|feed|feeds|post|posts|me|search)/.test(ctx.request.url)) {
            if (ctx.mobile) {
                await send(ctx, './public/index.html')
            }
            else {
                await send(ctx, './public/pc.html')
            }
        }
        await next()
    }
}

上述就是配合前端 HTML5Mode 以及根据 UA 指向不同入口文件的中间件.

Koa2 的错误处理非常友好, 可以通过以下这样一个中间件来统一处理错误, 我们可以在这里捕捉到代码运行的大部分错误, 包括异步过程中的错误, 以及自己在代码中抛出的错误, 然后在这里统一的进行处理.

module.exports = function () {
    return async(ctx, next) => {
        try {
            await next()
        } catch (err) {
 
        }
    }
}

其实中间件听起来高大上其实并没什么, 你也可以直接写到 Koa 的入口文件, 但是比较好的风格是把他们都提取出来, 然后入口文件导入这些中间件进行 use 即可.

HTTP2

Koa2 使用 HTTP2 也非常简单.

http.createServer(app.callback()).listen(config.PORT)

// Production Only
if (config.ENV === 'production') {
    const options = {
        key:  config.APP.SSL_KEY,
        cert: config.APP.SSL_CERT,
        ca:   config.APP.CA
    }
    http2.createServer(options, app.callback()).listen(443)
}

我们可以配置两个配置文件分别作用于生产环境和开发环境, 然后在入口文件根据不同的环境使用即可.

另外也可以很方便的通过使用 koa-helmet 来配置 HSTS

Promise

虽然, Node 的主流版本都支持 Promise 了, 但是似乎原生的 Promise 仍然比 Bluebird 慢并且更消耗内存. 所以推荐使用 Bluebird 替代原生 Promise. 具体原因可以查看 [Why are native ES6 promises slower and more memory-intensive than bluebird?

可以把 mongoose 和全局的 promise 都换成 bluebird.

与此同时我们还获得了更加丰富的 Promise 方法如 promisify, 不用白不用.

mongoose.Promise = require('bluebird')
global.Promise = require('bluebird')

JWT

JWT 已经在前面的博客中提到过了, 我认为 JWT 保存 cookie 并设置 httpOnly, 同时传输非 httpOnly 的 XSRF-TOKEN 这种认证方式是比较妥当的. 如果使用了 HTTPS, 还可以同时设置 secure.

NPM scripts

npm scripts 非常好用, 我们可以自定义很多命令在里面. 例如我在项目中常用的:

"start": "pm2 start production.json",
"dev": "pm2 start development.json",
"angular": "pm2 start development.json",
"vue": "webpack-dev-server --hot --open --inline --progress",
"angular-dist": "gulp build",
"vue-dist": "NODE_ENV=production webpack --progress",
"build": "npm run angular-dist & npm run vue-dist",
"db": "mongod --dbpath='/root/db' --rest",
"generatessl": "./certbot-auto certonly --webroot -w /root/rss -d enjoyrss.com -d www.enjoyrss.com",
"updatessl": "./certbot-auto renew --quiet",
"cron": "crontab ./utils/cron"

在部署生成环境时运行 npm run build 构建最新版本, 运行 npm run updatessl 更新 SSL 证书, 运行npm start 来启动项目, 运行 npm run db 开启数据库.

Babel

用了 Babel 之后, 就可以随心所欲的书写 ES6 甚至 ES7 了.

我用的比较多的是 import , for...of... , async 以及解构赋值, 属性名表达式, 数组扩展符, 对象扩展符等等.

解构赋值和属性名表达式可以大大减少代码量, 写起来特别爽.

exports.list = async (ctx, next) => {
    let {
        order,
        limit,
        page,
        per_page,
        desc
    } = ctx.request.query
    
    let result = await FeedModel.find()
        .sort({
            [order]: desc === 'true' ? '1' : '-1'
        })
        .skip(+page * +per_page)
        .limit(+per_page || +limit)

    ctx.body = {
        success: true,
        data:    result
    }
}

数组扩展符和对象扩展符也很方便, 不用再使用 Array.concat 和 Object.assign 方法了.

// 数组扩展符
var a = [1, 2, 3]
var b = [4, 5, 6]
var c = [...a, 4, 5]	// [1, 2, 3, 4, 5]
var d = [...a, ...b]	// [1, 2, 3, 4, 5, 6]
// 对象扩展符
var feed = {
  _id:    123456,
  title:  'kkkk',
  unread: 6
}
var result = {
  ...feed,
  feed_id: 111111,
  unread:  10
}
// result: { _id: 123456, title: 'kkkk', unread: 10, feed_id: 11111 }

Async 异步函数也不是万能的, 它无法处理多个异步函数的同步处理问题. 这时候就要借助 Promise.all 了.

await Promise.all([
  Promise.resolve().then(async () => state = await UserPostModel.findOne({
    user_id,
    post_id: item
  })),
  Promise.resolve().then(async () => res = await PostModel.findById(item))
])

缓存

缓存在 Web 中非常重要

import { SHA256 } from 'crypto-js'

module.exports = function () {
    return async (ctx, next) => {
        if (ctx.request.method === 'GET') {
            if (/js|css|favicon|image/.test(ctx.path)) {
                ctx.cacheControl = {
                    maxAge: 60 * 60 * 24 * 180
                }
            }
        }
        await next()
    }
}

这个中间件给图片和文件设置了长达 180 天的缓存时间, 其实这里永久存储都 OK 了. 因为我的 JS 和 CSS 文件变化的话文件名和路径都变了, 图片是稳定不变的即使变了它的文件名也会变化. 因此我可以大胆的这样使用.

大部分工作我是交给了 koa-etagkoa-cache-control 这两个中间件来处理了. 现在项目我除了后端做好缓存之外, 前端也是进行了 JavaScript 的内存缓存和自动更新从而减少了请求数. 这样就大大减少了服务器的压力以及提升了用户的访问速度和体验.

PM2

PM2 是一个带有负载均衡功能的应用进程管理器. 我们可以用它来管理我们的 Node 进程, 它的用法也很简单. 使用 PM2 可以充分发挥服务器多核的特性(如果有的话), 并且实现0秒重载以及进程信息监控等. PM2 是比较通用的 Node 部署方式.

pm2.png

代码共用

由于前后端都是使用 JavaScript 这门语言, 不仅包管理统一, 大量的包如 Underscore 也可以前后端共用, 最方便的是大部分的工具函数也可以前后端一起使用, 例如检测邮箱和密码格式我们在前端做检测的同时, 后端也可以直接调用过来. 例如下面这个文件就可以同时在前端 Vue, Angular 以及后端中同时使用.

(function () {
    var help = {
        // 检测 URL 是否合法
        checkUrl(url) {
            let re = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/
            return re.test(url)
        },
        // 检测邮箱是否合法
        validateEmail(email) {
            let re = /\S+@\S+\.\S+/
            return re.test(email)
        },
        // 检验密码是否合法
        validatePassword(password) {
            let re = /\w{6,18}/
            return re.test(password)
        }
    }

    // In angular, the module name is app.tools, and the factory name is tools.
    if (typeof module !== 'undefined' && module.exports) {
        module.exports = help
    } else {
        angular.module('app.tools', []).factory('tools', function () {
            return help
        })
    }
}())

其实写 Node 相比起写前端要简单好多好多, 没有什么 Gulp, Webpack, 不用考虑文件体积和大小, 不用考虑模块化组件化之类的问题, 不用考虑客户端浏览器类型和浏览器版本, 不用追求各种极限速度(现在的自动雪碧图处理, 文件懒加载和前端框架的服务端渲染真的是没谁了...).

在 LNMP 架构中, 我们要使用 Nginx 来帮我们处理静态文件, 配置 PHP-FPM 来处理 PHP 请求. Gzip 和 HTTPS 等等都是在 Nginx 中配置. 其实这些事情 Node 都可以做了. 但并不意味着 Node 可以取代 Nginx, Nginx 不仅是一个 Web 服务器也是一个反向代理服务器. 必要的时候我们还是要使用 Nginx 来做一个反向代理.

以上.