簡(jiǎn)介
參考博客: 全棧開(kāi)發(fā)實(shí)戰(zhàn):用Vue2+Koa1開(kāi)發(fā)完整的前后端項(xiàng)目(更新Koa2)
前置技能: 具備Vue
和Koa
基礎(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
安裝Vue
和Vue 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)航到/admin
,router-view
標(biāo)簽渲染為/components/pages/Admin.vue
-
/components/pages/Admin.vue
(User.vue
類似):該組件data
的menus
屬性是一個(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.vue
(UC.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端口渴频,可以看到如下界面
本節(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
不支持es6
的import
語(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-json
和koa-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è)試一下:
連接數(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)建兩張表,user
和role
箫柳;使用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)就可以方便地使用user
和Role
進(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ì)象綁定到Header
的currentUser
屬性恬叹。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
解密Header
的authorization
字段停做,得到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)name
和passwd
是否合法:
/* /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
/api/user
將上一個(gè)接口測(cè)試返回的token添加到請(qǐng)求Header
,測(cè)試結(jié)果如圖:
本節(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)題的方式總體有三種:
- 聲明允許跨域
- 使用反向代理代理轉(zhuǎn)發(fā),如使用
nginx
將發(fā)送到8080端口反浓,/api
前綴的請(qǐng)求轉(zhuǎn)發(fā)到3000端口萌丈,使得在瀏覽器“看來(lái)”請(qǐng)求并沒(méi)有跨域 - 消除跨域,將前端代碼打包成靜態(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ǎ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àn)椋?code>Koa的默認(rèn)返回Content-Type
是application/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
為例(截圖只做演示,文件名參考上文):
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命令拷貝
dist
、node_modules
等目錄下的文件亿笤,添加.dockerignore
文件
- 將構(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
- 將運(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
- 打包項(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)
- 添加
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)注明出處