一个基于 Koa2 构建的类似于 Rails 的 nodejs 开源项目

lpzlby144 8年前
   <p>最近研究了下Koa2框架,喜爱其中间件的思想。但是发现实在是太简洁了,只有基本功能,虽然可以方便搭各种服务,但是离可以适应快速开发的网站框架还是有点距离。于是参考Rails的大致框架搭建了个网站框架kails, 配合postgres和redis, 实现了MVC架构,前端webpack,react前后端同构等网站开发基本框架。本文主要介绍kails搭建中的各种技术栈和思想。</p>    <p>koa来源于express的主创团队,主要利用es6的generators特性实现了基于中间件思想的新的框架,但是和express不同,koa并不想express一样提供一个可以满足基本网站开发的框架,而更像是一个基本功能模块,要满足网站还是需要自己引入很多功能模块。所以根据选型大的不同,有各种迥异的koa项目,kails由名字也可以看出是一个类似Ruby on Rails的koa项目。</p>    <p>项目地址: <a href="/misc/goto?guid=4959714274645428812" rel="nofollow,noindex">https://github.com/embbnux/kails</a> 欢迎Pull Request</p>    <p>主要目录结构如下:</p>    <pre>  <code class="language-javascript">├── 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</code></pre>    <h2><strong>一、第一步es6支持</strong></h2>    <p>kails选用的是koa2作为核心框架,koa2使用es7的async和await等功能,node在开启harmony后还是不能运行,所以要使用babel等语言转化工具进行支持:</p>    <p>babel6配置文件:</p>    <p>.babelrc:</p>    <pre>  <code class="language-javascript">{    "presets": [      "es2015",      "stage-0",      "react"    ]  }</code></pre>    <p>在入口使用babel加载整个功能,使支持es6</p>    <pre>  <code class="language-javascript">require('babel-core/register')  require('babel-polyfill')  require('./app.js')</code></pre>    <h2><strong>二、核心文件app.js</strong></h2>    <p>app.js是核心文件,koa2的中间件的引入和使用主要在这里,这里会引入各种中间件和配置, 具体详细功能介绍后面会慢慢涉及到。</p>    <p>下面是部分内容,具体内容见github上仓库</p>    <pre>  <code class="language-javascript">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</code></pre>    <h2><strong>三、MVC框架搭建</strong></h2>    <p>网站架构还是以mvc分层多见和实用,能满足很多场景的网站开发了,逻辑再复杂点可以再加个服务层,这里基于koa-router进行路由的分发,从而实行MVC分层</p>    <p>路由的配置主要由routes/index.js文件去自动加载其目录下的其它文件,每个文件负责相应的路由头下的路由分发,如下</p>    <p>routes/index.js</p>    <pre>  <code class="language-javascript">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</code></pre>    <p>路由文件主要负责把相应的请求分发到对应controller中,路由主要采用restful分格。</p>    <p>routes/articles.js</p>    <pre>  <code class="language-javascript">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</code></pre>    <p>model层这里基于Sequelize实现orm对接底层数据库postgres, 利用sequelize-cli实现数据库的迁移功能.</p>    <p>例子:</p>    <p>user.js</p>    <pre>  <code class="language-javascript">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  }</code></pre>    <h2><strong>四、开发、测试与线上环境</strong></h2>    <p>网站开发测试与部署等都会有不同的环境,也就需要不同的配置,这里我主要分了development,test和production环境,使用时用自动基于NODE_ENV变量加载不同的环境配置。</p>    <p>实现代码:</p>    <p>config/config.js</p>    <pre>  <code class="language-javascript">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;</code></pre>    <p>生产环境的配置:</p>    <p>config/production.js</p>    <pre>  <code class="language-javascript">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  };</code></pre>    <h2><strong>五、利用中间件优化代码</strong></h2>    <p>koa是以中间件思想构建的,自然代码中离不开中间件,这里介绍几个中间件的应用</p>    <p>currentUser的注入:</p>    <p>currentUser用于获取当前登录用户,在网站用户系统上中具有重要的重要</p>    <pre>  <code class="language-javascript">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()  })</code></pre>    <p>这样在以后的中间件中就可以通过ctx.state.currentUser得到当前用户</p>    <p>优化controller代码</p>    <p>比如article的controller里的edit和update,都需要找到当前的article对象,也需要验证权限,而且是一样的,为了避免代码重复,这里也可以用中间件</p>    <p>controllers/articles.js</p>    <pre>  <code class="language-javascript">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()  }</code></pre>    <p>在路由中应用中间件</p>    <pre>  <code class="language-javascript">router.put('/:id', articles.checkLogin, articles.checkArticleOwner, articles.update)  router.get('/:id/edit', articles.checkLogin, articles.checkArticleOwner, articles.edit)</code></pre>    <p>这样就相当于实现了rails的before_action的功能</p>    <h2><strong>六、webpack配置静态资源</strong></h2>    <p>在没实现前后端分离前,工程代码中肯定还是少不了前端代码,现在在webpack是前端模块化编程比较出名的工具,这里用它来做rails中assets pipeline的功能,这里介绍下基本的配置。</p>    <p>config/webpack/base.js</p>    <pre>  <code class="language-javascript">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'      })    ]  };</code></pre>    <h2><strong>七、react前后端同构</strong></h2>    <p>node的好处是v8引擎只要是js就可以跑,所以想react的渲染dom功能也可以在后端渲染,有利用实现react的前后端同构,利于seo,对用户首屏内容也更加友好。</p>    <p>在前端跑react我就不说了,这里讲下在koa里面怎么实现的:</p>    <pre>  <code class="language-javascript">import React from 'react'  import { renderToString } from 'react-dom/server'  async function index(ctx, next) {    const prerenderHtml = await renderToString(      <Articles articles={ articles } />    )  }</code></pre>    <h2><strong>八、测试与lint</strong></h2>    <p>测试和lint自然是开发过程中工程化不可缺少的一部分,这里kails的测试采用mocha,lint使用eslint</p>    <p>.eslintrc:</p>    <pre>  <code class="language-javascript">{    "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"  }</code></pre>    <h2><strong>九、console</strong></h2>    <p>用过rails的,应该都知道rails有个rails console,可以已命令行的形式进入网站的环境,很是方便,这里基于repl实现:</p>    <pre>  <code class="language-javascript">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)  }</code></pre>    <h2><strong>十、pm2部署</strong></h2>    <p>开发完自然是要部署到线上,这里用pm2来管理:</p>    <pre>  <code class="language-javascript">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"</code></pre>    <h2><strong>十一、npm scripts</strong></h2>    <p>有些常用命令参数较多,也比较长,可以使用npm scripts里为这些命令做一些别名</p>    <pre>  <code class="language-javascript">{    "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"    }  }</code></pre>    <p>这样就会多出这些命令:</p>    <pre>  <code class="language-javascript">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</code></pre>    <h2><strong>十二、更进一步</strong></h2>    <p>现在目前能想到的</p>    <ul>     <li>性能优化,加快响应速度</li>     <li>Dockerfile简化部署</li>     <li>线上代码预编译</li>     <li>更加完善的测试</li>    </ul>    <p> </p>    <p>来自:https://www.embbnux.com/2016/09/04/kails_with_koa2_like_ruby_on_rails/</p>    <p> </p>