[Kails] A Node.js Web App like Ruby on Rails with Koa2, Webpack and Postgres

Kails is a koa v2 web app built as Ruby on Rails redis with many middlewares. it has features such as MVC architecture, assets with webpack, different environments. Hope it can help you to learn about koa and build a web app quickly.

最近研究了下Koa2框架,喜爱其中间件的思想。但是发现实在是太简洁了,只有基本功能,虽然可以方便搭各种服务,但是离可以适应快速开发的网站框架还是有点距离。于是参考Rails的大致框架搭建了个网站框架kails, 配合postgres和redis, 实现了MVC架构,前端webpack,react前后端同构等网站开发基本框架。本文主要介绍kails搭建中的各种技术栈和思想。

kails就是本站的源码哦,本站为kails的production实践

本文首发于Blog of Embbnux, 转载请注明原文出处: [Kails] 一个基于 Koa2 构建的类似于 Rails 的 nodejs 开源项目 https://www.embbnux.com/2016/09/04/kails_with_koa2_like_ruby_on_rails/

koa来源于express的主创团队,主要利用es6的generators特性实现了基于中间件思想的新的框架,但是和express不同,koa并不想express一样提供一个可以满足基本网站开发的框架,而更像是一个基本功能模块,要满足网站还是需要自己引入很多功能模块。所以根据选型大的不同,有各种迥异的koa项目,kails由名字也可以看出是一个类似Ruby on Rails的koa项目。

项目地址: https://github.com/embbnux/kails

主要目录结构如下:

├── app
│   ├── assets
│   │   ├── images
│   │   ├── javascripts
│   │   └── stylesheets
│   ├── controllers
│   ├── helpers
│   ├── models
│   ├── routes
│   ├── services
│   ├── views
│   └── index.js
├── config
│   ├── config.js
│   └── webpack
│       ├── base.js
│       ├── development.js
│       └── production.js
├── db
│   └── migrations
├── index.js
├── package.json
├── public
└── test

一、第一步es6支持

kails选用的是koa2作为核心框架,koa2使用es7的async和await等功能,node在开启harmony后还是不能运行,所以要使用babel等语言转化工具进行支持: babel6配置文件: .babelrc:

{
  "presets": [
    "es2015",
    "stage-0",
    "react"
  ]
}

在入口使用babel加载整个功能,使支持es6

require('babel-core/register')
require('babel-polyfill')
require('./app/index.js')

二、核心文件app/index.js

app/index.js是核心文件,koa2的中间件的引入和使用主要在这里,这里会引入各种中间件和配置, 具体详细功能介绍后面会慢慢涉及到。

下面是部分内容,具体内容见github上仓库

import Koa from 'koa'
import session from 'koa-generic-session'
import csrf from 'koa-csrf'
import views from 'koa-views'
import convert from 'koa-convert'
import json from 'koa-json'
import bodyParser from 'koa-bodyparser'

import config from '../config/config'
import router from './routes/index'
import koaRedis from 'koa-redis'
import models from './models/index'

const redisStore = koaRedis({
  url: config.redisUrl
})

const app = new Koa()

app.keys = [config.secretKeyBase]

app.use(convert(session({
  store: redisStore,
  prefix: 'kails:sess:',
  key: 'kails.sid'
})))

app.use(bodyParser())
app.use(convert(json()))
app.use(convert(logger()))

// not serve static when deploy
if(config.serveStatic){
  app.use(convert(require('koa-static')(__dirname + '/public')))
}

//views with pug
app.use(views('./views', { extension: 'pug' }))

// csrf
app.use(convert(csrf()))

app.use(router.routes(), router.allowedMethods())

app.listen(config.port)
export default app

三、MVC框架搭建

网站架构还是以mvc分层多见和实用,能满足很多场景的网站开发了,逻辑再复杂点可以再加个服务层,这里基于koa-router进行路由的分发,从而实行MVC分层 路由的配置主要由routes/index.js文件去自动加载其目录下的其它文件,每个文件负责相应的路由头下的路由分发,如下 app/routes/index.js

import fs from 'fs'
import path from 'path'
import Router from 'koa-router'

const basename = path.basename(module.filename)
const router = Router()

fs
  .readdirSync(__dirname)
  .filter(function(file) {
    return (file.indexOf('.') !== 0) && (file !== basename) && (file.slice(-3) === '.js')
  })
  .forEach(function(file) {
    let route = require(path.join(__dirname, file))
    router.use(route.routes(), route.allowedMethods())
  })

export default router

路由文件主要负责把相应的请求分发到对应controller中,路由主要采用restful分格。 app/routes/articles.js

import Router from 'koa-router'
import articles from '../controllers/articles'

const router = Router({
  prefix: '/articles'
})
router.get('/new', articles.checkLogin, articles.newArticle)
router.get('/:id', articles.show)
router.put('/:id', articles.checkLogin, articles.checkArticleOwner, articles.checkParamsBody, articles.update)
router.get('/:id/edit', articles.checkLogin, articles.checkArticleOwner, articles.edit)
router.post('/', articles.checkLogin, articles.checkParamsBody, articles.create)

// for require auto in index.js
module.exports = router

model层这里基于Sequelize实现orm对接底层数据库postgres, 利用sequelize-cli实现数据库的迁移功能. 例子: user.js

import bcrypt from 'bcrypt'

export default function(sequelize, DataTypes) {
  const User = sequelize.define('User', {
    id: {
      type: DataTypes.INTEGER,
      primaryKey: true,
      autoIncrement: true
    },
    name: {
      type: DataTypes.STRING,
      validate: {
        notEmpty: true,
        len: [1, 50]
      }
    },
    email: {
      type: DataTypes.STRING,
      validate: {
        notEmpty: true,
        isEmail: true
      }
    },
    passwordDigest: {
      type: DataTypes.STRING,
      field: 'password_digest',
      validate: {
        notEmpty: true,
        len: [8, 128]
      }
    },
    password: {
      type: DataTypes.VIRTUAL,
      allowNull: false,
      validate: {
        notEmpty: true
      }
    },
    passwordConfirmation: {
      type: DataTypes.VIRTUAL
    }
  },{
    underscored: true,
    tableName: 'users',
    indexes: [{ unique: true, fields: ['email'] }],
    classMethods: {
      associate: function(models) {
        User.hasMany(models.Article, { foreignKey: 'user_id' })
      }
    },
    instanceMethods: {
      authenticate: function(value) {
        if (bcrypt.compareSync(value, this.passwordDigest)){
          return this
        }
        else{
          return false
        }
      }
    }
  })
  function hasSecurePassword(user, options, callback) {
    if (user.password != user.passwordConfirmation) {
      throw new Error('Password confirmation doesn\'t match Password')
    }
    bcrypt.hash(user.get('password'), 10, function(err, hash) {
      if (err) return callback(err)
      user.set('passwordDigest', hash)
      return callback(null, options)
    })
  }
  User.beforeCreate(function(user, options, callback) {
    user.email = user.email.toLowerCase()
    if (user.password){
      hasSecurePassword(user, options, callback)
    }
    else{
      return callback(null, options)
    }
  })
  User.beforeUpdate(function(user, options, callback) {
    user.email = user.email.toLowerCase()
    if (user.password){
      hasSecurePassword(user, options, callback)
    }
    else{
      return callback(null, options)
    }
  })
  return User
}

四、开发、测试与线上环境

网站开发测试与部署等都会有不同的环境,也就需要不同的配置,这里我主要分了development,test和production环境,使用时用自动基于NODE_ENV变量加载不同的环境配置。 实现代码: config/config.js

var _ = require('lodash');
var development = require('./development');
var test = require('./test');
var production = require('./production');

var env = process.env.NODE_ENV || 'development';
var configs = {
  development: development,
  test: test,
  production: production
};
var defaultConfig = {
  env: env
};

var config = _.merge(defaultConfig, configs[env]);

module.exports = config;

生产环境的配置: config/production.js

const port = Number.parseInt(process.env.PORT, 10) || 5000
module.exports = {
  port: port,
  hostName: process.env.HOST_NAME_PRO,
  serveStatic: process.env.SERVE_STATIC_PRO || false,
  assetHost: process.env.ASSET_HOST_PRO,
  redisUrl: process.env.REDIS_URL_PRO,
  secretKeyBase: process.env.SECRET_KEY_BASE
};

五、利用中间件优化代码

koa是以中间件思想构建的,自然代码中离不开中间件,这里介绍几个中间件的应用

currentUser的注入:

currentUser用于获取当前登录用户,在网站用户系统上中具有重要的重要

app.use(async (ctx, next) => {
  let currentUser = null
  if(ctx.session.userId){
    currentUser = await models.User.findById(ctx.session.userId)
  }
  ctx.state = {
    currentUser: currentUser,
    isUserSignIn: (currentUser != null)
  }
  await next()
})

这样在以后的中间件中就可以通过ctx.state.currentUser得到当前用户

优化controller代码

比如article的controller里的edit和update,都需要找到当前的article对象,也需要验证权限,而且是一样的,为了避免代码重复,这里也可以用中间件 app/controllers/articles.js

async function edit(ctx, next) {
  const locals = {
    title: '编辑',
    nav: 'article'
  }
  await ctx.render('articles/edit', locals)
}

async function update(ctx, next) {
  let article = ctx.state.article
  article = await article.update(ctx.state.articleParams)
  ctx.redirect('/articles/' + article.id)
  return
}

async function checkLogin(ctx, next) {
  if(!ctx.state.isUserSignIn){
    ctx.status = 302
    ctx.redirect('/')
    return
  }
  await next()
}

async function checkArticleOwner(ctx, next) {
  const currentUser = ctx.state.currentUser
  const article = await models.Article.findOne({
    where: {
      id: ctx.params.id,
      userId: currentUser.id
    }
  })
  if(article == null){
    ctx.redirect('/')
    return
  }
  ctx.state.article = article
  await next()
}

在路由中应用中间件

router.put('/:id', articles.checkLogin, articles.checkArticleOwner, articles.update)
router.get('/:id/edit', articles.checkLogin, articles.checkArticleOwner, articles.edit)

这样就相当于实现了rails的before_action的功能

六、webpack配置静态资源

在没实现前后端分离前,工程代码中肯定还是少不了前端代码,现在在webpack是前端模块化编程比较出名的工具,这里用它来做rails中assets pipeline的功能,这里介绍下基本的配置。 config/webpack/base.js

var webpack = require('webpack');
var path = require('path');
var publicPath = path.resolve(__dirname, '../', '../', 'public', 'assets');
var ManifestPlugin = require('webpack-manifest-plugin');
var assetHost = require('../config').assetHost;
var ExtractTextPlugin = require('extract-text-webpack-plugin');

module.exports = {
  context: path.resolve(__dirname, '../', '../'),
  entry: {
    application: './assets/javascripts/application.js',
    articles: './assets/javascripts/articles.js',
    editor: './assets/javascripts/editor.js'
  },
  module: {
    loaders: [{
      test: /\.jsx?$/,
      exclude: /node_modules/,
      loader: ['babel-loader'],
      query: {
        presets: ['react', 'es2015']
      }
    },{
      test: /\.coffee$/,
      exclude: /node_modules/,
      loader: 'coffee-loader'
    },
    {
      test: /\.(woff|woff2|eot|ttf|otf)\??.*$/,
      loader: 'url-loader?limit=8192&name=[name].[ext]'
    },
    {
      test: /\.(jpe?g|png|gif|svg)\??.*$/,
      loader: 'url-loader?limit=8192&name=[name].[ext]'
    },
    {
      test: /\.css$/,
      loader: ExtractTextPlugin.extract("style-loader", "css-loader")
    },
    {
      test: /\.scss$/,
      loader: ExtractTextPlugin.extract('style', 'css!sass')
    }]
  },
  resolve: {
    extensions: ['', '.js', '.jsx', '.coffee', '.json']
  },
  output: {
    path: publicPath,
    publicPath: assetHost + '/assets/',
    filename: '[name]_bundle.js'
  },
  plugins: [
    new webpack.ProvidePlugin({
      $: 'jquery',
      jQuery: 'jquery'
    }),
    // new webpack.HotModuleReplacementPlugin(),
    new ManifestPlugin({
      fileName: 'kails_manifest.json'
    })
  ]
};

七、react前后端同构

node的好处是v8引擎只要是js就可以跑,所以想react的渲染dom功能也可以在后端渲染,有利用实现react的前后端同构,利于seo,对用户首屏内容也更加友好。 在前端跑react我就不说了,这里讲下在koa里面怎么实现的:

import React from 'react'
import { renderToString } from 'react-dom/server'
async function index(ctx, next) {
  const prerenderHtml = await renderToString(
    <Articles articles={ articles } />
  )
}

八、测试与lint

测试和lint自然是开发过程中工程化不可缺少的一部分,这里kails的测试采用mocha,lint使用eslint .eslintrc:

{
  "parser": "babel-eslint",
  "root": true,
  "rules": {
    "new-cap": 0,
    "strict": 0,
    "no-underscore-dangle": 0,
    "no-use-before-define": 1,
    "eol-last": 1,
    "indent": [2, 2, { "SwitchCase": 0 }],
    "quotes": [2, "single"],
    "linebreak-style": [2, "unix"],
    "semi": [1, "never"],
    "no-console": 1,
    "no-unused-vars": [1, {
      "argsIgnorePattern": "_",
      "varsIgnorePattern": "^debug$|^assert$|^withTransaction$"
    }]
  },
  "env": {
    "browser": true,
    "es6": true,
    "node": true,
    "mocha": true
  },
  "extends": "eslint:recommended"
}

九、console

用过rails的,应该都知道rails有个rails console,可以已命令行的形式进入网站的环境,很是方便,这里基于repl实现:

if (process.argv[2] && process.argv[2][0] == 'c') {
  const repl = require('repl')
  global.models = models
  repl.start({
    prompt: '> ',
    useGlobal: true
  }).on('exit', () => { process.exit() })
}
else {
  app.listen(config.port)
}

十、pm2部署

开发完自然是要部署到线上,这里用pm2来管理:

NODE_ENV=production ./node_modules/.bin/pm2 start index.js -i 2 --name "kails" --max-memory-restart 300M --merge-logs --log-date-format="YYYY-MM-DD HH:mm Z" --output="log/production.log"

十一、npm scripts

有些常用命令参数较多,也比较长,可以使用npm scripts里为这些命令做一些别名

{
  "scripts": {
    "console": "node index.js console",
    "start": "./node_modules/.bin/nodemon index.js & node_modules/.bin/webpack --config config/webpack.config.js --progress --colors --watch",
    "app": "node index.js",
    "pm2": "NODE_ENV=production ./node_modules/.bin/pm2 start index.js -i 2 --name \"kails\" --max-memory-restart 300M --merge-logs --log-date-format=\"YYYY-MM-DD HH:mm Z\" --output=\"log/production.log\"",
    "pm2:restart": "NODE_ENV=production ./node_modules/.bin/pm2 restart \"kails\"",
    "pm2:stop": "NODE_ENV=production ./node_modules/.bin/pm2 stop \"kails\"",
    "pm2:monit": "NODE_ENV=production ./node_modules/.bin/pm2 monit \"kails\"",
    "pm2:logs": "NODE_ENV=production ./node_modules/.bin/pm2 logs \"kails\"",
    "test": "NODE_ENV=test ./node_modules/.bin/mocha --compilers js:babel-core/register --recursive --harmony --require babel-polyfill",
    "assets_build": "node_modules/.bin/webpack --config config/webpack.config.js",
    "assets_compile": "NODE_ENV=production node_modules/.bin/webpack --config config/webpack.config.js -p",
    "webpack_dev": "node_modules/.bin/webpack --config config/webpack.config.js --progress --colors --watch",
    "lint": "eslint . --ext .js",
    "db:migrate": "node_modules/.bin/sequelize db:migrate",
    "db:rollback": "node_modules/.bin/sequelize db:migrate:undo",
    "create:migration": "node_modules/.bin/sequelize migration:create"
  }
}

这样就会多出这些命令:

npm install
npm run db:migrate
NODE_ENV=test npm run db:migrate
# run for development, it start app and webpack dev server
npm run start
# run the app
npm run app
# run the lint
npm run lint
# run test
npm run test
# deploy
npm run assets_compile
NODE_ENV=production npm run db:migrate
npm run pm2

十二、更进一步

目前kails实现了基本的博客功能,有基本的权限验证,以及markdown编辑等功能. 现在目前能想到更进一步的:

  • 性能优化,加快响应速度
  • Dockerfile简化部署
  • 线上代码预编译