Node.js [Kails] 一个基于 Koa2 构建的类似于 Rails 的 nodejs 开源项目

embbnux · 2016年09月05日 · 最后由 embbnux 回复于 2016年09月09日 · 9989 次阅读

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

本文首发于Blog of Embbnux, 转载请注明原文出处: 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.js
├── assets
│   ├── images
│   ├── javascripts
│   └── stylesheets
├── config
│   ├── config.js
│   ├── development.js
│   ├── test.js
│   ├── production.js
│   └── webpack.config.js
│   ├── webpack
├── routes
├── models
├── controllers
├── views
├── db
│   └── migrations
├── helpers
├── 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.js')

二、核心文件 app.js

app.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 文件去自动加载其目录下的其它文件,每个文件负责相应的路由头下的路由分发,如下 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 分格。 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 对象,也需要验证权限,而且是一样的,为了避免代码重复,这里也可以用中间件 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 简化部署
  • 线上代码预编译

欢迎 pull request : https://github.com/embbnux/kails

不错,之前看到各种玩 nodejs 的都是出框架,这个脚手架真是业界良心。

#1 楼 @citysheep 哈哈,研究了挺久的

3 楼 已删除

手动点赞~虽然我并不会写 nodejs😂

#4 楼 @night_7th 可以了解下,用 es6 后感觉友好多了

一看就是花了不少时间琢磨如何组合,配置吧。

#6 楼 @lgn21st 是啊零零散散花了两个月,主要是每个功能都得去找找哪个是最优解,并不像 rails 默认就给一套最佳实践,不过配置好了也就快了

先点个赞再看代码 🆙

https://github.com/eggjs/egg

我现在那个大团队在搞这个,也是基于 Koa 的。

#9 楼 @huacnlee 早有耳闻,我是很喜欢 koa 的中间件思想,对于 rails 的人来说比较好理解

请教大神

let app = new Koa();

export default app;

class App extend Koa{}

export default new App();

哪种更推荐?

赞,楼主的 kails 很不错的说👏 👏 👏

#11 楼 @nty 都可以啊,个人习惯以及场景不一样

需要 登录 后方可回复, 如果你还没有账号请 注册新账号