[Kails] 一個基於 Koa2 構建的類似於 Rails 的 nodejs 開源專案

embbnux發表於2016-09-05

本文首發於 Blog of Embbnux,轉載請註明原文出處,並保留原文連結:
https://www.embbnux.com/2016/09/04/kails_with_koa2_like_ruby_on_rails

最近研究了下Koa2框架,喜愛其中介軟體的思想。但是發現實在是太簡潔了,只有基本功能,雖然可以方便搭各種服務,但是離可以適應快速開發的網站框架還是有點距離。於是參考Rails的大致框架搭建了個網站框架kails,配合postgres和redis,實現了MVC架構,前端webpack,react前後端同構等網站開發基本框架。本文主要介紹kails搭建中的各種技術棧和思想。

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

相關文章