Vue Koa開(kāi)發(fā)實(shí)戰(zhàn)

簡(jiǎn)介

參考博客: 全棧開(kāi)發(fā)實(shí)戰(zhàn):用Vue2+Koa1開(kāi)發(fā)完整的前后端項(xiàng)目(更新Koa2)
前置技能: 具備VueKoa基礎(chǔ)知識(shí)贫途,了解Javascript基礎(chǔ)語(yǔ)法(和混亂)长赞,了解Nodejs(npm)常用操作

本文以新手視角,從零開(kāi)始逐步構(gòu)建一個(gè)Vue+Koa2的web應(yīng)用锋叨,項(xiàng)目主要包括以下內(nèi)容:

  • 基于Vue組件構(gòu)建單頁(yè)面應(yīng)用子房,包含登錄饲宛、用戶、管理員視圖概作,由Vue Router控制頁(yè)面跳轉(zhuǎn)
  • 使用Koa及相關(guān)插件提供API接口
  • Sequelize數(shù)據(jù)庫(kù)訪問(wèn)
  • 基于json web token的登錄驗(yàn)證
  • 配置本地運(yùn)行腋妙、打包docker鏡像部署

為了簡(jiǎn)化構(gòu)建(因?yàn)椴?,前端部分使用了Vue Cli讯榕,Cli的本質(zhì)依舊是使用Webpack打包骤素,但提供了一系列針對(duì)Vue的配置,使構(gòu)建過(guò)程開(kāi)箱即用愚屁;另外Login.vue使用了“參考博客”的源碼济竹。項(xiàng)目在一些階段會(huì)打tag,并附上源碼地址

由于以前從未接觸過(guò)nodejs后臺(tái)開(kāi)發(fā)霎槐,本文可能存在一些局限和錯(cuò)誤送浊,歡迎指正

創(chuàng)建項(xiàng)目

安裝Nodejs,建議更換淘寶源丘跌,鏡像地址袭景,指令:

npm config set registry https://registry.npm.taobao.org

安裝VueVue Cli

npm install vue
npm install -g @vue/cli
# 若使用vue serve和vue build命令需要安裝全局?jǐn)U展
npm install -g @vue/cli-service-global

創(chuàng)建項(xiàng)目

vue create vue-koa

新建server目錄,作為koa代碼目錄闭树,在目錄下創(chuàng)建app.js作為入口文件耸棒,整體目錄結(jié)構(gòu)如下:

.
├── README.md
├── babel.config.js
├── node_modules
│   └── ...
├── package-lock.json
├── package.json
├── public
│   ├── favicon.ico
│   └── index.html
├── server # 后端源碼目錄
│   └── app.js # 后端入口
└── src # 前端源碼目錄
    ├── App.vue # vue根組件,main.js中將該組件掛載到index.html中
    ├── assets
    │   └── logo.png
    ├── components
    │   └── HelloWorld.vue
    └── main.js # 前端入口

本節(jié)源碼:GitHub Tag V0.0

接口定義

項(xiàng)目使用jwt token做登錄驗(yàn)證报辱,用戶登錄點(diǎn)擊登錄時(shí)榆纽,前端調(diào)用獲取token接口,使用用戶名和密碼認(rèn)證捏肢,接口返回經(jīng)jwt加密的token奈籽;隨后,前端發(fā)送所有請(qǐng)求均攜帶該token作為已登錄憑證

按照標(biāo)準(zhǔn)鸵赫,token類型為Bearer衣屏,對(duì)需要權(quán)限認(rèn)證的接口,request header設(shè)置字段{Authorization: 'Bearer <token>'}辩棒;對(duì)于認(rèn)證失敗的請(qǐng)求狼忱,服務(wù)器應(yīng)當(dāng)返回401膨疏,response header設(shè)置字段{'WWW-Authenticate': 'Bearer'}

后端服務(wù)運(yùn)行在3000端口,提供兩個(gè)接口:

獲取token

請(qǐng)求參數(shù)

Method: POST
Api: /api/auth
Body: {username: un, password: pw}

返回值

{
  "code": 2000,
  "token": "eyqk"
}

獲取當(dāng)前用戶信息

接口需要攜帶token钻弄,請(qǐng)求參數(shù)

Method: GET
Api: /api/user

返回值

{
  "username": "艾廣威",
  "roles": [
    "user"
  ],
  "iat": 1567656871,
  "exp": 1567660471
}

前端頁(yè)面構(gòu)建

這一節(jié)笨鸡,將創(chuàng)建一個(gè)具有兩級(jí)導(dǎo)航結(jié)構(gòu)的頁(yè)面,頁(yè)面頂部導(dǎo)航欄為一級(jí)導(dǎo)航勒极,側(cè)邊導(dǎo)航菜單為二級(jí)導(dǎo)航敞恋。點(diǎn)擊頂部導(dǎo)航的菜單項(xiàng),切換側(cè)邊導(dǎo)航菜單瘤泪;點(diǎn)擊側(cè)邊導(dǎo)航菜單灶泵,切換頁(yè)面主體內(nèi)容

項(xiàng)目使用Vue Cli構(gòu)建,在根目錄下創(chuàng)建vue.config.js,該文件會(huì)自動(dòng)被Vue Cli識(shí)別对途。由于沒(méi)有對(duì)babel做額外調(diào)整赦邻,可將babel.config.js文件刪除

引入U(xiǎn)I等組件

引入element ui組件庫(kù),簡(jiǎn)化頁(yè)面排版

安裝方式: npm i element-ui -S
這里使用全局使用方式实檀,實(shí)際項(xiàng)目中建議按需引入惶洲,參考文檔

/* /src/main.js */
import ElementUI from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'

Vue.use(ElementUI);

引入Vuejs Logger, 方便打印log

安裝方式:npm install vuejs-logger --save-exact

/* /src/main.js */
import vueLogger from 'vuejs-logger'

Vue.use(vueLogger)

引入Vue Router 建立二級(jí)路由

安裝Vue Router,指令:npm install vue-router

/src下建立如下目錄結(jié)構(gòu):

.
├── App.vue # Vue根組件膳犹,包含頂部導(dǎo)航欄和一級(jí) router-view 標(biāo)簽
├── assets
│   └── logo.png
├── components
│   ├── pages
│   │   ├── Admin.vue # 管理員視圖湃鹊,包含管理員側(cè)邊導(dǎo)航菜單元數(shù)據(jù)
│   │   ├── Login.vue
│   │   ├── Logout.vue
│   │   ├── User.vue # 用戶視圖
│   │   ├── admin
│   │   │   └── AC.vue
│   │   └── user
│   │       └── UC.vue
│   └── parts # 公用頁(yè)面組件
│       ├── PageFooter.vue
│       └── SideMenuContent.vue # 側(cè)邊導(dǎo)航+主內(nèi)容(二級(jí) router-view 標(biāo)簽)
├── main.js
├── router.js # 前端路由配置
└── utils.js

頁(yè)面結(jié)構(gòu)分析:

  • /App.vue:頁(yè)面的根組件,定義頂部導(dǎo)航欄(一級(jí)導(dǎo)航)镣奋、底部頁(yè)腳币呵。中部是router-view標(biāo)簽,提供一級(jí)路由切換侨颈,如:點(diǎn)擊導(dǎo)航欄的“管理員”余赢,導(dǎo)航到/adminrouter-view標(biāo)簽渲染為/components/pages/Admin.vue
  • /components/pages/Admin.vueUser.vue類似):該組件datamenus屬性是一個(gè)列表對(duì)象哈垢,定義了側(cè)邊導(dǎo)航菜單的內(nèi)容妻柒;使用SideMenuContent.vue模板渲染menus,支持二級(jí)菜單
  • /components/pages/parts/SideMenuContent.vue:左側(cè)為側(cè)邊導(dǎo)航(二級(jí)導(dǎo)航)耘分,右側(cè)包含一個(gè)二級(jí)router-view標(biāo)簽举塔,用作二級(jí)路由渲染
  • /components/pages/AC.vueUC.vue類似):頁(yè)面主內(nèi)容,由SideMenuContent內(nèi)的router-view渲染
  • 更多細(xì)節(jié)查看本節(jié)結(jié)束給出的源碼

接下來(lái)配置Vue Router

/* /src/router.js */
import VueRouter from 'vue-router'
import Logout from './components/pages/Logout.vue'
import Login from './components/pages/Login.vue'
import User from './components/pages/User.vue'
import UC from './components/pages/user/UC.vue'
import Admin from './components/pages/Admin.vue'
import AC from './components/pages/admin/AC.vue'

const routes = [
    {
        path: '/user', component: User,
        children: [
            {
                path: 'info', component: UC
            }
        ]
    },{
        path: '/admin', component: Admin,
        children: [
            {
                path: 'info', component: AC
            }
        ]
    },{
        path: '/login', component: Login
    },
    {
        path: '/logout', component: Logout
    }
];

const router = new VueRouter({
    mode: 'history', //使用history模式求泰,避免url的host和uri之間顯示很丑的"#"
    routes: routes
});

export default router

main.js中引入router

import VueRouter from 'vue-router'
import router from './router.js'

new Vue({
  router: router,
  render: h => h(App),
}).$mount('#app')

由于我在/src/components/parts/SideMenuContent.vue動(dòng)態(tài)創(chuàng)建了新的組件央渣,需要啟用運(yùn)行時(shí)編譯

配置啟用運(yùn)行時(shí)編譯:

/* /vue.config.js */
module.exports = {
    runtimeCompiler: true
}

此時(shí)運(yùn)行npm run serve,訪問(wèn)8080端口渴频,可以看到如下界面

![基本頁(yè)面]](https://upload-images.jianshu.io/upload_images/16884359-2567cbeddbf64972.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

本節(jié)源碼:GitHub Tag V0.1

后端服務(wù)搭建

安裝koa芽丹,指令:npm install koa

后端服務(wù)需要實(shí)現(xiàn)以下功能:

  • 數(shù)據(jù)庫(kù)訪問(wèn)
  • 一個(gè)路由組件,提供接口定義章節(jié)定義的兩個(gè)接口卜朗,以及接口的訪問(wèn)權(quán)限控制
  • 一組中間件拔第,負(fù)責(zé)請(qǐng)求的預(yù)處理和后處理

后端目錄結(jié)構(gòu)如下:

.
├── app.js
├── config.js
├── const.js
├── controller
│   ├── auth-controller.js
│   └── user-controller.js
├── middlewares
│   ├── auth
│   │   ├── auth-maker.js
│   │   └── jwt-resolver.js
│   └── error-handler.js
├── router.js # 路由咕村,從controller導(dǎo)入
├── schema # 數(shù)據(jù)庫(kù)初始化,及各表定義
│   ├── db.js
│   ├── role.js
│   └── user.js
├── secrets # 敏感信息蚊俺,應(yīng)加入.gitignore
│   ├── db.json # 數(shù)據(jù)庫(kù)配置
│   └── jwt-key.txt # jwt密鑰懈涛,純文本字符串
├── service
│   └── user-service.js
└── utils.js

配置運(yùn)行環(huán)境

應(yīng)確保刪除了/babel.config.js,否則會(huì)默認(rèn)被babel加載導(dǎo)致啟動(dòng)失敗

由于node不支持es6import語(yǔ)法泳猬,這里需要使用babel做簡(jiǎn)單的語(yǔ)法轉(zhuǎn)換批钠。開(kāi)發(fā)環(huán)境下使用@babel/register運(yùn)行時(shí)轉(zhuǎn)換即可(生產(chǎn)環(huán)境會(huì)在之后的章節(jié)解釋),首先安裝@babel/register

npm install --save-dev @babel/core @babel/cli @babel/preset-env
npm install --save-dev babel-preset-env
npm install @babel/register --save-dev

在根目錄下添加server.dev.js文件暂殖,代碼如下:

/* /server.dev.js */
require('@babel/register')({
  'presets': [
    ['env', {
      'targets': {
        'node': true
      }
    }]
  ]
})
require('./server/app.js')

package.json中添加啟動(dòng)腳本

{
  "scripts": {
    "serve-koa": "node server.dev.js"
  }
}

稍后就可以使用npm run serve-koa啟動(dòng)后端服務(wù)

定義通用中間件

安裝koa-jsonkoa-bodyparser

npm install koa-json
npm install koa-bodyparser
  • koa-json:用于自動(dòng)序列化ctx.body中的Object對(duì)象
  • koa-bodyparser:用于將ctx中的formData解析到ctx.request.body

main.js中引入兩個(gè)中間件价匠,另外簡(jiǎn)單定義一個(gè)打印hello world的中間件当纱,代碼如下:

import Koa from 'koa'
import koaBodyParser from 'koa-bodyparser'
import json from 'koa-json'
import path from 'path'

const app = new Koa();

app.use(koaBodyParser());
app.use(json());
app.use(async (ctx, next) => {
    ctx.body = { msg: "Hello World", path: ctx.path, method: ctx.method };
    await next();
});

app.listen(3000);

使用npm run serve-koa啟動(dòng)服務(wù)呛每,使用Postman測(cè)試一下:

中間件測(cè)試

連接數(shù)據(jù)庫(kù)

如果使用8.0以上版本的mysql,sequelize可能會(huì)報(bào)錯(cuò)坡氯,stackoverflow相關(guān)鏈接
解決方式:ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'password'

項(xiàng)目使用mysql數(shù)據(jù)庫(kù)存儲(chǔ)用戶數(shù)據(jù)晨横,在數(shù)據(jù)庫(kù)中創(chuàng)建兩張表,userrole箫柳;使用sequelize框架進(jìn)行數(shù)據(jù)庫(kù)操作手形,首先安裝sequelize和數(shù)據(jù)庫(kù)驅(qū)動(dòng):

npm install --save sequelize
npm install --save mysql2

數(shù)據(jù)庫(kù)配置信息以json文件格式存放在/server/secrets/db.json中,格式如下:

{
    "host": "10.143.53.100",
    "port": 3306,
    "schema": "vueDemo",
    "username": "root",
    "password": "root"
} 

/server/secrets/config.js中加載配置文件(同時(shí)也加載了jwt的密鑰悯恍,這樣做是為了方便后期部署時(shí)库糠,將secrets目錄下的文件存儲(chǔ)到docker中):

/* /server/config.js */
import fs from "fs";
import path from 'path'

let secretPath = 'secrets'

export default {
    SECRET: fs.readFileSync(path.resolve(__dirname, secretPath, 'jwt-key.txt')),
    EXP_TIME: '1h',
    DATA_BASE: JSON.parse(fs.readFileSync(path.resolve(__dirname, secretPath, 'db.json')))
}

接下來(lái)配置sequelize并導(dǎo)出數(shù)據(jù)庫(kù)上下文對(duì)象

/* /server/schema/db.js */
import Sequelize from 'sequelize'
import config from '../config.js'

const dbConfig = config.DATA_BASE;
const sequelize = new Sequelize(`mysql://${dbConfig.username}:${dbConfig.password}@${dbConfig.host}:${dbConfig.port}/${dbConfig.schema}`,
    {
        pool: { //數(shù)據(jù)庫(kù)連接池
            max: 5,
            min: 1,
            acquire: 30000,
            idle: 10000
        }
    })

export default sequelize

安裝uuid用作自增主鍵,指令:npm install uuid

然后創(chuàng)建user表對(duì)應(yīng)的對(duì)象(role表類似)

/* /server/schema/user.js */
import Sequelize from 'sequelize'
import sequelize from './db.js'
import uuid from 'uuid'

const Model = Sequelize.Model;
class User extends Model { }

User.init({
    id: {
        type: Sequelize.UUID,
        defaultValue: uuid(), // id為空時(shí)涮毫,使用uuid自動(dòng)生成主鍵
        primaryKey: true
    },
    name: {
        type: Sequelize.STRING,
        allowNull: false
    },
    passwd: {
        type: Sequelize.STRING,
        allowNull: false
    }
}, {
        sequelize,
        modelName: 'user'
    })

User.sync().then(() => { console.log('== Table: User init!') }); //初始化數(shù)據(jù)庫(kù)瞬欧,如果表不存在則自動(dòng)創(chuàng)建

export default User

接下來(lái)就可以方便地使用userRole進(jìn)行數(shù)據(jù)庫(kù)訪問(wèn)

實(shí)現(xiàn)接口

按照接口定義章節(jié)的描述,需要實(shí)現(xiàn)兩個(gè)接口罢防,其中/api/user需要鑒權(quán)艘虎,/api/auth可在未登錄狀態(tài)訪問(wèn)

整體思路及代碼結(jié)構(gòu)如下:

  • /server/middlewares/auth目錄存放權(quán)限驗(yàn)證相關(guān)代碼,jwt-resolver.js解析請(qǐng)求的Header咒吐,解密authorization屬性得到User對(duì)象(包括id野建、name和roles屬性),將對(duì)象綁定到HeadercurrentUser屬性恬叹。auth-maker.js導(dǎo)出一個(gè)check方法候生,接受ctx對(duì)象和一個(gè)requireRole屬性,當(dāng)ctx.request.currentUser不具備requireRole時(shí)拋出異常绽昼;可以將該方法放在需要權(quán)限驗(yàn)證的controller代碼開(kāi)始處

  • /server/controller下的兩個(gè)controller對(duì)應(yīng)兩個(gè)接口

  • /server/middlewares/error-handler.js攔截所有異常陶舞,并為statusCode為401的請(qǐng)求設(shè)置response header->{ 'WWW-Authenticate': 'Bearer' }

User service

user-service.js中添加以下方法,后面會(huì)用到:

/* /server/service/user-service.js */
import User from '../schema/user'
import Role from '../schema/role';
import { ROLE_USER } from '../const';

export default {
    getUser: async (id) => {}, //返回id對(duì)應(yīng)的User對(duì)象绪励,如果不存在返回null
    checkUser: async (name, passwd) => {}, //返回name和passwd符合的User對(duì)象肿孵,不存在則返回null
    getRoles: async uid => { //返回該uid對(duì)應(yīng)user具有的roles唠粥,不存在則返回ROLE_USER并更新數(shù)據(jù)庫(kù)
        let rolesModel = await Role.findAll({ where: { uid: uid } });
        if (rolesModel.length <= 0) ... //省略更新邏輯
        const roles = [];
        rolesModel.forEach(r => roles.push(r.role))
        return roles
    }
}

權(quán)限中間件

安裝jsonwebtoken,指令:npm install jsonwebtoken

jwt-resolver.js解密Headerauthorization字段停做,得到user對(duì)象晤愧,代碼如下:

/* /server/middlewares/auth/jwt-resolver.js */
import jwt from 'jsonwebtoken'
import config from '../../config.js'
import userService from '../../service/user-service.js'

export default async (ctx, next) => {
    let token;
    let authHeader = ctx.header.authorization; //從header中取出token
    if (authHeader) {
        let [authType, jwtToken] = authHeader.split(' '); 
        if (authType.toLowerCase() === 'bearer') {
            try {
                token = jwt.verify(jwtToken, config.SECRET); //使用jwt解密token
                ctx.header.currentUser = token; //將解析得到的user對(duì)象綁定到currentUser
            } catch (e) {
                console.log('Unresolved jwt token', e)
            }
        }
    }
    await next();
    // 省略自動(dòng)更新token相關(guān)代碼
}

auth-maker.js導(dǎo)出check方法,代碼如下:

/* /server/middlewares/auth/auth-maker.js */
export default {
    check: (ctx, requiredRole) => {
        let user = ctx.header.currentUser;
        if(!user){
            ctx.throw(401, "4010::Unauthorized"); // 未登錄(提供token)
        }
        if(!user.roles.includes(requiredRole)){
            ctx.throw(401, "4011::PmissionDenied"); // 權(quán)限不足蛉腌,如:roles=['user'], requiredRole='admin'
        }
    }
}

配置路由

auth-controller.js實(shí)現(xiàn)了/api/auth接口官份,訪問(wèn)數(shù)據(jù)庫(kù)檢驗(yàn)namepasswd是否合法:

/* /server/controller/auth-controller.js */
import jwt from 'jsonwebtoken'
import config from '../config.js'
import userService from '../service/user-service.js'
import userService from '../service/user-service.js'

export default {
    getAuth: async (ctx, next) => {
        const auth = ctx.request.body;
        const user = await userService.checkUser(auth.name, auth.passwd);
        if(!user){ // name和passwd錯(cuò)誤時(shí),拋出異常
            ctx.throw(401, "4010::Username or password error!")
        }
        const roles = await userService.getRoles(user.id) // 獲取用戶具有的role
        const token = {
            id: user.id,
            name: user.name,
            passwd: user.passwd,
            roles: roles
        }
        ctx.body = { code: 2000, token: jwt.sign(token, config.SECRET, { expiresIn: config.EXP_TIME }) }; // 簽名token烙丛,返回
    }
}

user-controller.js與上面類似舅巷,只是在入口處進(jìn)行權(quán)限驗(yàn)證:

/* /server/controller/user-controller.js */
import authMaker from '../middlewares/auth/auth-maker.js'
import { ROLE_USER } from '../const.js';

export default {
    getUser: async ctx => {
        authMaker.check(ctx, ROLE_USER) //檢驗(yàn)用戶是否具有ROLE_USER權(quán)限,不滿足時(shí)拋異常
        ctx.body = ctx.header.currentUser
    }
}

安裝koa-router河咽, 指令:npm install koa-router

接下來(lái)在router.js中引入以上兩個(gè)controller钠右,并指定對(duì)應(yīng)的接口:

/* /server/router.js */
import koaRouter from 'koa-router'
import auth from './controller/auth-controller.js'
import user from './controller/user-controller.js'

const router = koaRouter();
router.prefix('/api'); //對(duì)所有路由添加'/api'前綴

router.post('/auth', auth.getAuth); // 指定訪問(wèn)'/api/auth'的請(qǐng)求由auth.getAuth方法處理
router.get('/user', user.getUser);

export default router

異常捕獲

error-handler.js中捕獲由中間件或controller拋出的異常并處理

/* /server/middlewares/error-handler.js */
import utils from '../utils.js'

export default async (ctx, next) => {
    try {
        await next();
    } catch (e) {
        ctx.status = e.statusCode || e.status || 500; //捕獲異常并設(shè)置statusCode,默認(rèn)500
        // '4010::Unauthorized' -> 業(yè)務(wù)錯(cuò)誤代碼:4010;錯(cuò)誤信息:Unauthorized
        let [code, msg] = e.message.split('::'); 
        ctx.body = utils.errMsg(Number(code), msg);
        switch (ctx.status) {
            case 401: // 對(duì)401權(quán)限錯(cuò)誤設(shè)置指示“系統(tǒng)接受認(rèn)證方式”的header
                ctx.set({ 'WWW-Authenticate': 'Bearer' });
                break;
        }
    }
}

引入以上組件

將以上的組件添加到app.js中忘蟹,此時(shí)代碼看起來(lái)應(yīng)該是這樣:

/* /server/app.js */
import Koa from 'koa'
import koaBodyParser from 'koa-bodyparser'
import json from 'koa-json'
import path from 'path'
import errorHandler from './middlewares/error-handler.js'
import jwtResolver from './middlewares/auth/jwt-resolver.js'
import router from './router.js'

const app = new Koa();

app.use(errorHandler)
app.use(koaBodyParser());
app.use(json());
app.use(jwtResolver);
app.use(async (ctx, next) => {
    ctx.body = { msg: "Hello World", path: ctx.path, method: ctx.method };
    await next();
});
app.use(router.routes());

app.listen(3000);

需要提前創(chuàng)建數(shù)據(jù)庫(kù)飒房,但不需要提前創(chuàng)建表

運(yùn)行npm run serve-koa,啟動(dòng)服務(wù)媚值,控制臺(tái)打雍萏骸:

> vue-koa@0.1.0 serve-koa D:\pcode\vue-koa
> node server.dev.js

Executing (default): CREATE TABLE IF NOT EXISTS `users` (`id` CHAR(36) BINARY DEFAULT 'fc0870f8-faf7-4f23-9eee-65f869bff791' , `name` VARCHAR(255) NOT NULL, `passwd` VARCHAR(255) NOT NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB;
Executing (default): CREATE TABLE IF NOT EXISTS `roles` (`id` CHAR(36) BINARY DEFAULT 'df463adb-be07-4c6c-9db7-46be31fbf725' , `uid` VARCHAR(255) NOT NULL, `role` VARCHAR(255) NOT NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB;
Executing (default): SHOW INDEX FROM `roles`
== Table: Role init!
Executing (default): SHOW INDEX FROM `users`
== Table: User init!

向數(shù)據(jù)庫(kù)生成的user表中插入一條記錄:

INSERT INTO `vueDemo`.`users` (`name`, `passwd`) VALUES ('root', 'root');

接下來(lái)使用postman進(jìn)行接口測(cè)試:

  • /api/auth
接口測(cè)試1
  • /api/user

將上一個(gè)接口測(cè)試返回的token添加到請(qǐng)求Header,測(cè)試結(jié)果如圖:

接口測(cè)試2

本節(jié)源碼:GitHub Tag V0.2

前后端對(duì)接

前端頁(yè)面構(gòu)建章節(jié)中褥芒,我們實(shí)現(xiàn)了基本的頁(yè)面跳轉(zhuǎn)邏輯嚼松;本節(jié)將在此基礎(chǔ)上,對(duì)接后端服務(wù)锰扶,實(shí)現(xiàn)登錄驗(yàn)證

前端登錄流程:

  • 用戶在登錄界面點(diǎn)擊登錄献酗,前端將用戶名和密碼發(fā)送給/api/auth接口獲取token
  • 將獲取到的token存儲(chǔ)到瀏覽器的sessionStorage
  • 前端訪問(wèn)后端接口的請(qǐng)求都攜帶該token
  • 設(shè)置路由守衛(wèi),跳轉(zhuǎn)到受保護(hù)路由時(shí)檢測(cè)sessinStroge少辣,若token無(wú)效則跳轉(zhuǎn)到登錄界面

項(xiàng)目使用fetch發(fā)送http請(qǐng)求凌摄,為了確保所有請(qǐng)求均攜帶token,并能響應(yīng)token過(guò)期漓帅、無(wú)效等情況锨亏,可以對(duì)fetch做簡(jiǎn)單的封裝放到utils.js

另外,前后端分離會(huì)導(dǎo)致跨域問(wèn)題忙干,簡(jiǎn)單來(lái)說(shuō):假設(shè)前端服務(wù)運(yùn)行在localhost:8080器予,后端服務(wù)運(yùn)行在localhost:3000端口,由于瀏覽器中的頁(yè)面是由8080端口的前端服務(wù)返回捐迫,那么頁(yè)面的js代碼只能發(fā)送到localhost:8080的請(qǐng)求乾翔,在頁(yè)面中調(diào)用3000端口的api屬于跨域。解決這個(gè)問(wèn)題的方式總體有三種:

  1. 聲明允許跨域
  2. 使用反向代理代理轉(zhuǎn)發(fā),如使用nginx將發(fā)送到8080端口反浓,/api前綴的請(qǐng)求轉(zhuǎn)發(fā)到3000端口萌丈,使得在瀏覽器“看來(lái)”請(qǐng)求并沒(méi)有跨域
  3. 消除跨域,將前端代碼打包成靜態(tài)文件雷则,掛到后端服務(wù)下

這里采用第二種辆雾,在前端定義一個(gè)代理,轉(zhuǎn)發(fā)/api前綴的請(qǐng)求月劈,在vue.config.js中添加:

/* /vue.config.js */
module.exports = {
    devServer: {
        proxy: {
            '/api': {
                target: 'http://localhost:3000/',
                changeOrigin: true
            }
        }
    }
}

登錄驗(yàn)證

登錄驗(yàn)證邏輯在Login.vue中度迂,部分代碼如下:

/* /src/components/pages/Login.vue */
import utils from "../../utils.js"

export default {
  data() {...}, //定義account, password, targetUrl(=this.$route.query.targetUrl)
  methods: {
    login() {
      let auth = { //綁定到form表單的數(shù)據(jù)
        name: this.account,
        passwd: this.password
      };
      fetch("/api/auth", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(auth)
      })
        .then(res => res.json())
        .then(res => {
          if (res.code === 2000) {
            utils.saveToken(res.token); //將token保存到sessionStroge
            this.onAuthSuccess();
          } else {
            this.onAuthFail();
          }
        });
    },
    onAuthSuccess() {
      if (this.targetUrl) { //判斷用戶是直接訪問(wèn)登錄頁(yè)還是被重定向登錄頁(yè)
        this.$router.push({ path: this.targetUrl });
      } else {
        this.$router.push({ path: "/" });
      }
    },
    onAuthFail() {...}
  }
};

注銷登陸非常簡(jiǎn)單猜揪,只需清空sessionStroge即可

路由守衛(wèi)

router.js中惭墓,添加路由守衛(wèi),在路由跳轉(zhuǎn)前判斷路由是否受保護(hù)而姐,以及sessionStroge中是否存儲(chǔ)了有效token

/* /src/router.js */
import utils from './utils.js'

router.beforeEach((to, from, next) => {
    if (to.path === '/login' || utils.vaildToken()) {
        next();
    } else {
        // targetUrl記錄當(dāng)前url腊凶,以便登錄成功后跳轉(zhuǎn)會(huì)當(dāng)前頁(yè)面
        next({path: '/login', query: {targetUrl: to.fullPath}})
    }
});

/* /src/utils.js */
function vaildToken() {
    const token = sessionStorage.getItem('access-token');
    const exp = sessionStorage.getItem('exp');
    return token && (Date.now() < exp * 1000) ? true : false;
}

export default {
    vaildToken: vaildToken
}

封裝fetch

這部分主要做三件事:發(fā)送請(qǐng)求前將token設(shè)置到header;收到響應(yīng)后判斷是否需要更新本地token毅人;若請(qǐng)求失敗吭狡,生成錯(cuò)誤提示尖殃。wrappedFetch部分代碼如下:

/* /src/utils */
async function wrappedFetch(resource, init) {
    let token = getToken();
    if (token) {
        init.headers.Authorization = 'Bearer ' + token; //添加header
    }
    let res = await fetch(resource, init);
    let r = await res.clone().json();
    if (res.ok) {
        if (r.ut) { //如果ut(updateToken)字段非空丈莺,則更新本地token
            saveToken(r.ut);
        }
        return res;
    } else {...} //處理請(qǐng)求失敗的情況
}

export default {
    wrappedFetch: wrappedFetch
}

AC.vue中,當(dāng)點(diǎn)擊refresh按鈕時(shí)送丰,使用wrappedFetch訪問(wèn)/api/auth接口缔俄,刷新user數(shù)據(jù):

/* /src/components/pages/admin */
export default {
  methods: {
    refreshUser() {
      utils
        .wrappedFetch("/api/user", { methods: "GET" })
        .then(res => res.json())
        .then(res => {
          this.user = res;
        })
        .catch(e => this.$log.info("Server error", e));
    }
  }
};

登錄并訪問(wèn)http://localhost:8080/admin/info,點(diǎn)擊refresh器躏,測(cè)試結(jié)果如下:

前端調(diào)用接口測(cè)試

點(diǎn)擊“用戶中心”->“退出登陸”確認(rèn)功能正常

本節(jié)源碼:GitHub Tag V0.3

打包部署

開(kāi)發(fā)環(huán)境下俐载,分別為前后端啟動(dòng)服務(wù),可以方便地使用模塊熱重載特性(vue cli默認(rèn)支持登失,koa可以使用nodemon實(shí)現(xiàn))遏佣,有助于快速開(kāi)發(fā)。生產(chǎn)環(huán)境下揽浙,將前端構(gòu)建成靜態(tài)文件状婶,掛載到被babel轉(zhuǎn)換過(guò)的后端代碼下,可以提供更好的性能馅巷。

項(xiàng)目構(gòu)建

配置vue.config.js膛虫,設(shè)置vue cli構(gòu)建參數(shù):

/* /vue.config.js */
module.exports = {
    outputDir: 'dist/dist', // 構(gòu)建輸出目錄,將后端轉(zhuǎn)換后的代碼放在dist下钓猬,將前端構(gòu)建后的代碼放在后端的dist下
    assetsDir: 'assets' // 提取asset到單獨(dú)文件夾
}

在項(xiàng)目下根目錄下添加server.babelrc稍刀,作為后端babel轉(zhuǎn)換的配置文件,轉(zhuǎn)換目標(biāo)為node

{
    "presets": [
        [
            "@babel/preset-env",
            {
                "targets": {
                    "node": true
                }
            }
        ]
    ]
}

配置package.json中的啟動(dòng)腳本:

  • serve-vue 開(kāi)發(fā)環(huán)境啟動(dòng)前端
  • serve-koa 開(kāi)發(fā)環(huán)境啟動(dòng)后端
  • build 構(gòu)建前端
  • compile 轉(zhuǎn)換后端
  • buildAll 構(gòu)建前后端
  • start 生產(chǎn)環(huán)境啟動(dòng)項(xiàng)目
{
  "scripts": {
    "serve-vue": "vue-cli-service serve --port 80",
    "serve-koa": "node server.dev.js",
    "build": "vue-cli-service build",
    "compile": "babel server -d dist --config-file ./server.babelrc --copy-files",
    "buildAll": "npm run compile && npm run build",
    "start": "cd dist && node app.js",
    "lint": "vue-cli-service lint"
  }
}

安裝koa static敞曹,指令:npm install koa-static

還需要在后端代碼中使用koa-static配置靜態(tài)資源服務(wù)器账月,當(dāng)所有路由匹配失敗時(shí)嘗試加載靜態(tài)資源

安裝histroy api fallback综膀,指令:npm install koa2-history-api-fallback

另外由于前端使用了Vue Router的history路由模式,形如/login的請(qǐng)求(hash模式下對(duì)應(yīng)為/index.html# /login)是無(wú)法找到對(duì)應(yīng)的靜態(tài)資源的局齿。該請(qǐng)求的本質(zhì)是請(qǐng)求/index.html頁(yè)面僧须,然后執(zhí)行前端路由router.push('/login')。所以需要添加historyApiFallback项炼,將所有未匹配到后端路由的(前端)路由映射到index.html

代碼如下:

/* /server/app.js */
...
app.use(router.routes());
// 一定放在router之后
app.use(historyApiFallback());
app.use(serve(path.resolve('dist')));

app.listen(3000);

至此担平,全部配置就完成了,然后我們運(yùn)行npm run buildAll && npm run start锭部,訪問(wèn)localhost:3000/login暂论,不出意外會(huì)看到以下界面:

頁(yè)面未渲染

這是因?yàn)椋?code>Koa的默認(rèn)返回Content-Typeapplication/json,而koa static未能正確設(shè)置該屬性拌禾。我們可以使用mime-types識(shí)別資源類型取胎,手動(dòng)設(shè)置Content-Type

安裝mime-types,指令:npm install mime-types

app.js中添加一個(gè)中間件:

/* /server/app.js */
app.use(historyApiFallback());
app.use(async (ctx, next)=>{
    await next();
    ctx.set('content-type', mime.lookup(path.join('dist', ctx.path)) || 'text/html');
})
app.use(serve(path.resolve('dist')));

重新構(gòu)建并運(yùn)行湃窍,即可看到正確的頁(yè)面

docker構(gòu)建

/server/secrets下存儲(chǔ)了數(shù)據(jù)庫(kù)和jwt密鑰等敏感信息闻蛀,應(yīng)當(dāng)添加到.gitignore中,避免上傳到github您市;同時(shí)我們不希望打包好的docker鏡像中包含這些信息觉痛,而是從docker secret中加載。

項(xiàng)目的依賴可以分為運(yùn)行時(shí)依賴和開(kāi)發(fā)環(huán)境依賴茵休,為了使最終的鏡像只包含運(yùn)行時(shí)依賴薪棒,以及避免每次構(gòu)建重新安裝依賴,我們需要分別打包構(gòu)建環(huán)境榕莺、運(yùn)行時(shí)環(huán)境鏡像俐芯,并使用兩階段構(gòu)建生成最終鏡像

存儲(chǔ)敏感信息

首先在項(xiàng)目部署的docker服務(wù)器上,使用docker secret存儲(chǔ)敏感信息钉鸯“墒罚可以使用docker secret create命令,參見(jiàn)docker文檔唠雕,或使用Portainer等工具贸营。

Portainer為例(截圖只做演示,文件名參考上文):

創(chuàng)建secret

secret會(huì)以文件的形式保存及塘,在docker-compose.yml中指定使用后莽使,會(huì)掛載到容器的/run/secrets下。接下來(lái)笙僚,修改后端的config.js芳肌,當(dāng)運(yùn)行環(huán)境為docker時(shí),從docker secrets中加載這些配置:

//
let secretPath = 'secrets'
if (process.env.ENV === 'docker') {
    secretPath = '/run/secrets'
}

export default {
    SECRET: fs.readFileSync(path.resolve(__dirname, secretPath, 'jwt-key.txt')),
    ...
}

打包docker鏡像

為了防止copy命令拷貝distnode_modules等目錄下的文件亿笤,添加.dockerignore文件

  1. 將構(gòu)建環(huán)境打包為單獨(dú)的鏡像翎迁,指令及build.Dockerfile
docker build -t vue-koa-build-env:latest -f ./dockerfiles/build.Dockerfile .
FROM node:lts-alpine
WORKDIR /build
COPY package*.json ./
RUN npm install
  1. 將運(yùn)行環(huán)境打包為單獨(dú)的鏡像,指令及runtime.Dockerfile
docker build -t vue-koa-runtime-env:latest -f ./dockerfiles/runtime.Dockerfile .
FROM node:lts-alpine

WORKDIR /app
COPY package*.json ./
RUN npm install --production
  1. 打包項(xiàng)目鏡像净薛,指令及Dockerfile
docker build -t vue-koa:latest -f ./dockerfiles/Dockerfile .
FROM vue-koa-build-env:latest # stage0: 基于構(gòu)建環(huán)境汪榔,構(gòu)建項(xiàng)目
WORKDIR /build
COPY . .
RUN npm run buildAll

FROM vue-koa-runtime-env:latest # stage1: 基于運(yùn)行環(huán)境,拷貝stage0的構(gòu)建結(jié)果
WORKDIR /app
COPY --from=0 /build/dist ./dist

ENV ENV="docker" # 設(shè)置環(huán)境變量
EXPOSE 3000
CMD ["npm", "run", "start"] # 啟動(dòng)
  1. 添加docker-compose.yml肃拜,配置加載的secrets痴腌,部分配置如下:
services:
  vue_koa:
    secrets:
      - db.json
      - jwt-key.txt

secrets:
  db.json:
    external: true
  jwt-key.txt:
    external: true

最后,在compose文件所在目錄燃领,執(zhí)行docker-compose up -d即可啟動(dòng)服務(wù)

本節(jié)源碼:GitHub Tag V0.4

寫(xiě)在最后

之前剛完成的一個(gè)項(xiàng)目士聪,使用了Flask+Jinja2+JQuery的技術(shù)棧,寫(xiě)的很不開(kāi)心:模板渲染+ajax混用導(dǎo)致代碼有些凌亂猛蔽;缺失ioc&aop剥悟;Flask沒(méi)有異步非阻塞……于是下一個(gè)項(xiàng)目選型的時(shí)候,我打算用SpringBoot+Vue曼库,但方案被領(lǐng)導(dǎo)駁回区岗,要求使用nodejs,于是就有了這篇新手向博客

驀然想起當(dāng)初面試百度的時(shí)候面試官的一句話:“語(yǔ)言不重要毁枯,重要的是...”慈缔,這句話的潛臺(tái)詞是“都得會(huì)!”后众。當(dāng)然無(wú)論是用Java胀糜、Python還是JavaScript寫(xiě)Web颅拦,思想都是相通的蒂誉,無(wú)非是不同語(yǔ)言提供了不同的特性

但是啊,曾經(jīng)滄海難為水距帅,當(dāng)年用Spring那一套時(shí)其實(shí)沒(méi)有覺(jué)得哪里便捷了右锨,面試問(wèn)起來(lái)也無(wú)非就會(huì)說(shuō)個(gè)AOP、IOC碌秸,至于好在哪里绍移,始終一知半解,直到有一天離開(kāi)了這生態(tài)讥电。之前在知乎吐槽Js沒(méi)有大型成熟的后端框架蹂窖,有回復(fù)“Nestjs了解一下”,我還真的去了解了一下恩敌,IOC怎么看怎么怪瞬测,AOP完善程度被Spring按在地上摩擦……加上ts的語(yǔ)法……怎么說(shuō)呢,ts是我見(jiàn)過(guò)最詭異最反直覺(jué)的語(yǔ)法,比golang都嚴(yán)重

還用就是鴨子型語(yǔ)言用多了月趟,真的挺懷念Java的……可能也就懷念下灯蝴,萬(wàn)一回去了,大概又不習(xí)慣冗長(zhǎng)的語(yǔ)法了孝宗,誰(shuí)知道呢

說(shuō)到底穷躁,語(yǔ)言不過(guò)是個(gè)工具……

博客發(fā)表于:Ghamster Blog
轉(zhuǎn)載請(qǐng)注明出處

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市因妇,隨后出現(xiàn)的幾起案子问潭,更是在濱河造成了極大的恐慌,老刑警劉巖婚被,帶你破解...
    沈念sama閱讀 206,214評(píng)論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件睦授,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡摔寨,警方通過(guò)查閱死者的電腦和手機(jī)去枷,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,307評(píng)論 2 382
  • 文/潘曉璐 我一進(jìn)店門(mén),熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)是复,“玉大人删顶,你說(shuō)我怎么就攤上這事∈缋龋” “怎么了逗余?”我有些...
    開(kāi)封第一講書(shū)人閱讀 152,543評(píng)論 0 341
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)季惩。 經(jīng)常有香客問(wèn)我录粱,道長(zhǎng),這世上最難降的妖魔是什么画拾? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 55,221評(píng)論 1 279
  • 正文 為了忘掉前任啥繁,我火速辦了婚禮,結(jié)果婚禮上青抛,老公的妹妹穿的比我還像新娘旗闽。我一直安慰自己,他們只是感情好蜜另,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,224評(píng)論 5 371
  • 文/花漫 我一把揭開(kāi)白布适室。 她就那樣靜靜地躺著,像睡著了一般举瑰。 火紅的嫁衣襯著肌膚如雪捣辆。 梳的紋絲不亂的頭發(fā)上,一...
    開(kāi)封第一講書(shū)人閱讀 49,007評(píng)論 1 284
  • 那天此迅,我揣著相機(jī)與錄音汽畴,去河邊找鬼促煮。 笑死,一個(gè)胖子當(dāng)著我的面吹牛整袁,可吹牛的內(nèi)容都是我干的菠齿。 我是一名探鬼主播,決...
    沈念sama閱讀 38,313評(píng)論 3 399
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼坐昙,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼绳匀!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起炸客,我...
    開(kāi)封第一講書(shū)人閱讀 36,956評(píng)論 0 259
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤疾棵,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后痹仙,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體是尔,經(jīng)...
    沈念sama閱讀 43,441評(píng)論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 35,925評(píng)論 2 323
  • 正文 我和宋清朗相戀三年开仰,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了拟枚。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 38,018評(píng)論 1 333
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡众弓,死狀恐怖恩溅,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情谓娃,我是刑警寧澤脚乡,帶...
    沈念sama閱讀 33,685評(píng)論 4 322
  • 正文 年R本政府宣布,位于F島的核電站滨达,受9級(jí)特大地震影響奶稠,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜捡遍,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,234評(píng)論 3 307
  • 文/蒙蒙 一锌订、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧稽莉,春花似錦瀑志、人聲如沸。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 30,240評(píng)論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)昧甘。三九已至良拼,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間充边,已是汗流浹背庸推。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 31,464評(píng)論 1 261
  • 我被黑心中介騙來(lái)泰國(guó)打工常侦, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人贬媒。 一個(gè)月前我還...
    沈念sama閱讀 45,467評(píng)論 2 352
  • 正文 我出身青樓聋亡,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親际乘。 傳聞我的和親對(duì)象是個(gè)殘疾皇子坡倔,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,762評(píng)論 2 345

推薦閱讀更多精彩內(nèi)容