V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
embbnux
V2EX  ›  Node.js

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

  •  
  •   embbnux · 2016-09-05 12:19:40 +08:00 · 4388 次点击
    这是一个创建于 2999 天前的主题,其中的信息可能已经有所发展或是发生改变。

    最近研究了下 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 简化部署
    • 线上代码预编译
    6 条回复    2016-11-04 23:27:47 +08:00
    Niphor
        1
    Niphor  
       2016-09-05 14:25:52 +08:00
    楼主 你这个同构...
    server 端不是用的 pug 么...
    embbnux
        2
    embbnux  
    OP
       2016-09-05 14:31:21 +08:00
    @Niphor 这一块还没做的满意,现在是 react seerver 渲染 react 部分成 string ,传给 pug
    embbnux
        3
    embbnux  
    OP
       2016-09-05 21:41:45 +08:00
    线上环境是这个: https://kails.org/
    Tmac15
        4
    Tmac15  
       2016-09-07 10:08:35 +08:00 via iPhone
    楼主的 kails 跟 rails 很像,超赞👍
    zhfish
        5
    zhfish  
       2016-10-07 19:55:05 +08:00
    node 估计快 7.0 LTS 了, 所以 async/await 可以直接尝试用 node6.x 了, v8 已支持,既然是新东西,离生产还有段距离,不如潮一点, 4.x 和 6.x 特性差的越来越多了
    embbnux
        6
    embbnux  
    OP
       2016-11-04 23:27:47 +08:00
    @zhfish 是啊直接换 7, 不过 7 应该不会是 lts 只有偶数会
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   1656 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 22ms · UTC 16:52 · PVG 00:52 · LAX 08:52 · JFK 11:52
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.