寫在前面
完成前端搭建后,我們需要搭建服務(wù)端代碼两芳。
首先獻(xiàn)上代碼倉庫
本文將有以下幾個模塊
- egg項目安裝運行
- 簡單的egg寫法示例摔寨,寫一個簡單的接口,聯(lián)通前后端
- 接入數(shù)據(jù)庫和JWT怖辆,完成登錄注冊的功能
- 以用戶信息查詢優(yōu)化為例是复,講講redis的安裝和使用
項目安裝運行
看到第三篇了,想必nodejs已經(jīng)裝上了
// 創(chuàng)建一個文件夾
mkdir myblog
cd myblog
// 初始化一個node項目
npm int
// 安裝egg包
npm i -S egg
npm i -D egg-bin
在package.json的script中添加一句"dev": "egg-bin dev"
竖螃,之后我們將使用npm run dev
命令來啟動項目淑廊。
egg項目需要按照規(guī)范的文件目錄來建立,否則啟動時會報錯
當(dāng)前新建項目的目錄結(jié)構(gòu)如圖
我們先依照上圖創(chuàng)建必要的文件夾和文件(上圖已做標(biāo)記)
router.js中定義一個路由規(guī)則
// app/router.js
module.exports = app => {
const { router, controller } = app;
router.get('/home/index', controller.home.index); // 首頁
router.redirect('/', '/home/index', 302); // 當(dāng)沒有指定路由的時候特咆,重定向到首頁
}
// app/controller/home.js
'use strict';
const Controller = require('egg').Controller;
class HomeController extends Controller {
async index() { // 首頁
this.ctx.body = 'hello world';
}
}
module.exports = HomeController;
config.**.js表示不同環(huán)境下的配置季惩,default是在各個環(huán)境下都生效的配置,實際上某個環(huán)境的最終配置,是default和相應(yīng)環(huán)境配置的合并蜀备。
配置中必須要有keys這個屬性关摇,其值是一個自定義的字符串,所以我們寫在config.default.js中碾阁。
config有多種exports的方法输虱,具體可以參見官方文檔,寫的很詳細(xì)脂凶。
// config/config.default.js
exports.keys = 'testblog';
有以上代碼宪睹,項目就可以跑起來了,執(zhí)行npm run dev
蚕钦,訪問 http://127.0.0.1:7001
可以看到亭病,路由被重定向到了 http://127.0.0.1:7001/home/index,頁面上顯示hello world嘶居。說明我們的項目已經(jīng)成功搭建并運行起來了
我的第一個接口
下面我們將定義一個post路由罪帖,用于分頁查詢用戶列表,并與前端聯(lián)通邮屁。
// router.js加一條路由配置
router.post('/home/list', controller.home.list); // 獲取用戶列表
// app/controller/home.js中增加一個list方法
async list() { // 用戶列表
const res = await this.ctx.service.home.list(this.ctx.request);
this.ctx.body = res;
this.ctx.status = res.status;
}
參考上面官方目錄結(jié)構(gòu)整袁,controller負(fù)責(zé)請求的接收和結(jié)果的返回,具體的業(yè)務(wù)邏輯(查詢數(shù)據(jù)庫之類的)應(yīng)該放到service層佑吝。所以我們在app下新建一個service文件夾坐昙,創(chuàng)建home.js∮蠓蓿可以通過this.ctx.service.home.XX訪問到該文件中定義的方法炸客。
// app/service/home.js
'use strict';
const Service = require('egg').Service;
class HomeService extends Service {
async list(data) { // 用戶列表
return {
message: 'ok',
status: 200,
result: {
list: [
{ id: 1, name: '王大娃' },
{ id: 2, name: '王二娃' },
{ id: 3, name: '王三娃' },
],
pageSize: data.pageSize,
current: data.current,
totalCount: 3,
total: 1
}
}
}
}
module.exports = HomeService;
list方法需要傳入一個參數(shù)data,從controller中可以看到戈钢,我們將客戶端的入?yún)his.ctx.request直接傳給了service的方法痹仙。
這里我們直接寫死假數(shù)據(jù)返回,先聯(lián)通前后端殉了,數(shù)據(jù)庫操作后面慢慢展開开仰。
接下來測試一下接口能不能正常訪問
我們可以先在瀏覽器控制臺中使用fetch方法寫個簡單的異步調(diào)用測試一下
// 瀏覽器控制臺中
fetch('http://127.0.0.1:7001/home/list', {
method: 'POST',
headers: {'content-type': 'application/json'},
body: JSON.stringify({ pageSize: 10, current: 1 })
})
敲回車,發(fā)現(xiàn)報了csrf token出錯
出這個錯誤的原因是:egg 框架內(nèi)置了安全系統(tǒng)宣渗,默認(rèn)開啟防止 XSS 攻擊 和 CSRF 攻擊抖所,每次請求得時候請求頭必須攜帶csrfToken字段梨州。
解決方法是痕囱,在config中指定校驗用的header屬性名稱,前端從cookie中取到csrfToken的值暴匠,將其放到請求header中鞍恢,即可通過驗證。
// config.default.js 添加一段
exports.security = {
csrf: {
headerName: 'x-csrf-token'
}
}
在瀏覽器控制臺中查到cookie中csrfToken對應(yīng)的值,將其傳入header中帮掉,然后重新請求弦悉,發(fā)現(xiàn)接口調(diào)通了。
// 瀏覽器控制臺中
fetch('http://127.0.0.1:7001/home/list', {
method: 'POST',
headers: {'content-type': 'application/json', 'x-csrf-token': 'V9357i1wopen1uSDoakpY7Ex'},
body: JSON.stringify({ pageSize: 10, current: 1 })
})
前端代碼中會用axios封裝請求蟆炊,需要在請求前傳入header
接入數(shù)據(jù)庫和JWT稽莉,實現(xiàn)登陸注冊功能
- 首先要安裝mysql和navicat(可視化的數(shù)據(jù)庫管理工具)
大家可以去官網(wǎng)下載,mysql推薦用社區(qū)版涩搓,不收費污秆。navicat下載后需要破解,百度很容易找到攻略昧甘,相信難不倒你 - 軟件安裝完畢后良拼,在egg項目中使用mysql,還得安裝egg-mysql這個包
npm i -S egg-mysql
- 在config中添加mysql相關(guān)配置 plugin.js中指定插件的使用情況
// config/config.default.js中添加一段
exports.mysql = {
client: {
// host
host: 'localhost',
// 端口號
port: '3306',
// 用戶名
user: 'root',
// 密碼
password: '你的密碼',
// 數(shù)據(jù)庫名
database: 'blog',
},
// 是否加載到 app 上充边,默認(rèn)開啟
app: true,
// 是否加載到 agent 上庸推,默認(rèn)關(guān)閉
agent: false,
};
// config/plugin.js
exports.mysql = {
enable: true, // 是否啟用mysql插件
package: 'egg-mysql', // 插件對應(yīng)的node包
};
-
在數(shù)據(jù)庫中創(chuàng)建表格,我們創(chuàng)建一個用戶表
- controller文件夾中創(chuàng)建user.js
'use strict';
const Controller = require('egg').Controller;
class UserController extends Controller {
async register() { // 注冊
const res = await this.ctx.service.user.register(this.ctx.request.body);
this.ctx.body = res;
this.ctx.status = res.status;
}
async login() { // 登錄
const res = await this.ctx.service.user.login(this.ctx.request.body);
this.ctx.body = res;
this.ctx.status = res.status;
}
}
module.exports = UserController;
- service文件夾中創(chuàng)建user.js
'use strict';
const Service = require('egg').Service;
const utility = require('utility');
const JWT = require('jsonwebtoken');
class UserService extends Service {
async register(data) { // 注冊
// 開啟事務(wù)
const result = await this.app.mysql.beginTransactionScope(async conn => {
const userList = [];
const now = Date.now();
data.password = utility.md5(data.password);
userList.push({
nick_name: data.nickName,
account: data.account,
password: data.password,
create_time: now,
update_time: now
});
try {
// 先校驗邀請碼是否正確
const res = await conn.select('invite', {
where: {
invite_code: data.code
}
});
if (!res || res.length === 0) {
return {
message: '該邀請碼不存在浇冰,請確認(rèn)后再試',
status: 501,
result: null
};
}
// 檢查用戶名是否重復(fù)
const accountCheck = await conn.query(`select id from user where account='${data.account}'`);
if (accountCheck && accountCheck.length > 0) {
return {
message: '用戶名重復(fù)贬媒,請修改后重試',
status: 501,
result: null
}
}
// 檢查昵稱是否重復(fù)
const nickNameCheck = await conn.query(`select id from user where nick_name='${data.nick_name}'`);
if (nickNameCheck && nickNameCheck.length > 0) {
return {
message: '昵稱重復(fù),請修改后重試',
status: 501,
result: null
}
}
// 寫入
const insertOpe = await conn.insert('user', userList);
if (insertOpe.affectedRows !== 1) {
console.log('寫入用戶信息出錯');
return {
message: '系統(tǒng)異常',
status: 501,
result: null
}
}
// 更新invite表
const updateRecord = await conn.query(`update invite set invite_count='${res[0].invite_count + 1}' where user_id='${res[0].user_id}'`);
if (updateRecord.affectedRows !== 1) {
console.log('更新邀請表出錯');
return {
message: '系統(tǒng)異常',
status: 501,
result: null
}
}
// 寫入邀請關(guān)系表
const insertRelation = await conn.insert('invite_relation', [{
user_id: res[0].user_id,
be_invited_user_id: insertOpe.insertId
}]);
if (insertRelation.affectedRows !== 1) {
console.log('寫入邀請關(guān)系表出錯');
return {
message: '系統(tǒng)異常',
status: 501,
result: null
}
}
return {
message: '注冊成功',
status: 200,
result: true
}
} catch (err) {
console.log(err);
return {
message: '系統(tǒng)異常湖饱,請稍后再試',
status: 501,
result: null
};
}
}, this.ctx);
return result;
}
async login(data) {
// 開啟事務(wù)
const result = await this.app.mysql.beginTransactionScope(async conn => {
try {
// 檢查賬號是否存在
const checkAccount = await conn.select('user', {
where: {
account: data.account
}
});
let password = '';
if (checkAccount && checkAccount.length > 0) {
password = checkAccount[0].password;
let inputPassword = utility.md5(data.password);
if (inputPassword === password) {
// 簽發(fā)jwt token
const token = JWT.sign({
userId: checkAccount[0].id,
nickName: checkAccount[0].nick_name,
account: checkAccount[0].account,
avatar: checkAccount[0].avatar
}, this.config.jwt.secret, {
expiresIn: this.config.jwt.expiresIn
});
return {
message: '登錄成功',
status: 200,
result: {
data: {
id: checkAccount[0].id,
nickName: checkAccount[0].nick_name,
account: checkAccount[0].account,
avatar: checkAccount[0].avatar
},
token
}
}
} else {
return {
message: '密碼錯誤掖蛤,請檢查拼寫',
status: 501,
result: null
}
}
} else {
return {
message: '該用戶名不存在,請檢查拼寫或者注冊新賬號',
status: 501,
result: null
}
}
} catch (err) {
return {
message: '系統(tǒng)異常井厌,請稍后再試',
status: 501,
result: null
};
}
});
return result;
}
}
module.exports = UserService;
- 由于注冊前有一步邀請碼驗證的操作蚓庭,所以注冊方法中有兩個關(guān)于邀請的表的操作,大家可以忽略掉先仅仆。
- service中的方法結(jié)構(gòu)是一樣的器赞,首先使用
this.app.mysql.beginTransactionScope()
方法新建一個事務(wù),然后在事務(wù)中進(jìn)行數(shù)據(jù)庫操作墓拜,當(dāng)有多個數(shù)據(jù)庫操作的時候港柜,其中一個出錯,之前的操作都會回滾咳榜,避免臟數(shù)據(jù)的出現(xiàn)夏醉。 - 用
try...catch...
語句進(jìn)行異常處理 - service中方法的返回格式是統(tǒng)一的
{
message: '返回的文案',
status: 'http狀態(tài)碼',
result: '操作的處理結(jié)果'
}
- 數(shù)據(jù)庫的增刪改查使用
conn
提供的select、delete涌韩、insert
等方法畔柔,如果sql比較復(fù)雜,可以用conn.query()
方法臣樱,直接傳入完整的sql語句進(jìn)行數(shù)據(jù)庫處理靶擦。更詳細(xì)的使用方法腮考,可以參考egg-mysql
的官方文檔。
- JWT的使用
- JWT是json web token的縮寫玄捕,是為了在網(wǎng)絡(luò)應(yīng)用環(huán)境間傳遞聲明而執(zhí)行的一種基于JSON的開放標(biāo)準(zhǔn)(RFC 7519).該token被設(shè)計為緊湊且安全的踩蔚,特別適用于分布式站點的單點登錄(SSO)場景。JWT的聲明一般被用來在身份提供者和服務(wù)提供者間傳遞被認(rèn)證的用戶身份信息枚粘,以便于從資源服務(wù)器獲取資源馅闽,也可以增加一些額外的其它業(yè)務(wù)邏輯所必須的聲明信息,該token也可直接被用于認(rèn)證馍迄,也可被加密捞蛋。
- jwt提供了
sign
和verify
兩個方法,sign
用于簽發(fā)token柬姚,verify
用于校驗token是否有效拟杉。 - 簽發(fā)的代碼可以參考上面service/user.js中的login方法,簽發(fā)時可以傳入一些非敏感用戶信息量承,在校驗的時候可以被解析出來搬设,用于確認(rèn)當(dāng)前訪問的用戶。
- 校驗的代碼可以參考
app/extend/helper.js中的checkLogin方法
- 關(guān)于JWT的詳細(xì)解析可以點這里撕捍,就不展開了拿穴。
jwt的安裝和使用
- 服務(wù)端要支持
CORS(跨來源資源共享)策略
npm i -S jsonwebtoken
npm i -S egg-cors
- 啟用插件
// config/plugin.js 增加一段
exports.cors = {
enable: true,
package: 'egg-cors'
}
// config/config.default.js 增加一段
exports.cors = {
origin: '*', // 不限制來源
allowMethods: 'GET,HEAD,PUT,POST,DELETE,PATCH,OPTIONS',
}
exports.jwt = {
enable: true,
secret: 'myblog', // 自定義的秘鑰,用于token的簽發(fā)和校驗忧风,不應(yīng)該流露出去
expiresIn: 60 * 60 // 有效時間默色,單位秒
};
- jwt工作流程
- 登錄接口中,當(dāng)用戶名密碼校驗通過后狮腿,用
jwt
簽發(fā)token腿宰,帶入上面配置的secret,expiresIn缘厢,以及用戶id吃度、用戶名、昵稱贴硫、頭像等非敏感信息
椿每,然后將token作為接口結(jié)果返回給客戶端。 - 客戶端拿到token后英遭,在之后的接口調(diào)用時间护,將token傳到header的
authorization
字段中里,當(dāng)有些接口需要登錄信息時挖诸,會去header中獲取token汁尺,并進(jìn)行jwt校驗,如果校驗不通過或者token不存在税灌,說明用戶未登錄或者登錄狀態(tài)過期均函,需要重新登錄。 - 服務(wù)端
jwt
校驗通過菱涤,可以獲得當(dāng)前訪問的用戶信息苞也,并進(jìn)行接口相應(yīng)的操作。
以上粘秆,登錄注冊的功能就完成了如迟。前端的代碼可以去前端項目倉庫中拉取細(xì)看。
redis的使用
我們可以通過緩存一些常用的數(shù)據(jù)攻走,從而達(dá)到減少數(shù)據(jù)庫操作的優(yōu)化目的殷勘。以獲取用戶信息的接口為例。
- 服務(wù)端收到接口請求昔搂,先判斷是否存在該用戶信息的緩存玲销。如果存在,直接將緩存作為接口結(jié)果返回摘符。
- 如果緩存不存在(沒有存或者緩存過期了)贤斜,則請求接口,并將結(jié)果存到緩存逛裤,返回請求的結(jié)果瘩绒。
- 如果用戶的信息發(fā)生變更,需要更新緩存带族。
接下來講講如何安裝使用redis
- 安裝和配置
本地下載redis工具锁荔,并啟動
// redis目錄下
redis-server.exe redis.windows.conf // 按照配置啟動redis
在node項目中配置
npm i -S egg-redis // 安裝redis的node包
// config/config.default.js 增加一段
exports.redis = {
client: {
port: 6379,
host: '127.0.0.1',
password: 'auth',
db: 0
}
}
// config/plugin.js 增加一段
exports.redis = {
enable: true,
package: 'egg-redis'
}
注意,當(dāng)項目中用到redis后蝙砌,再啟動本地服務(wù)前阳堕,要先啟動redis,否則會報錯
- 封裝redis的方法
redis
提供了獲取择克、寫入鍵值對辉川,查詢長度赋兵、值自增等方法,我將它們封裝進(jìn)一個app/service/cache.js
中。redis
更多方法可以參考egg-redis的文檔脐瑰。
// 以app/service/user.js中的porfile方法為例
async profile(id) {
const userId = id ? id : this.ctx.user.userId;
const userCache = await this.ctx.service.cache.get(`profile_${userId}`);
let result = null;
if (!userCache) {
// 開啟事務(wù)
result = await this.app.mysql.beginTransactionScope(async conn => {
try {
// 查詢用戶信息
const res = await conn.select('user', {
where: {
id: userId
}
});
if (res && res.length > 0) {
const obj = {
id: res[0].id,
nickName: res[0].nick_name,
account: res[0].account,
gender: res[0].gender,
birth: res[0].birth,
brief: res[0].brief,
website: res[0].website,
avatar: res[0].avatar,
userEmail: res[0].email,
telephone: res[0].telephone,
total_words: res[0].total_words,
article_count: res[0].article_count,
focus_count: res[0].focus_count,
fans_count: res[0].fans_count
}
await this.ctx.service.cache.set(`profile_${res[0].id}`, obj, 60 * 60);
return {
message: 'ok',
status: 200,
result: obj
};
} else {
return {
message: '查詢的用戶不存在',
status: 501,
result: null
};
}
} catch (err) {
return {
message: '系統(tǒng)異常,請稍后再試',
status: 501,
result: null
};
}
});
} else {
result = {
message: '讀取緩存',
status: 200,
result: userCache
}
}
return result;
}
將用戶信息的緩存癌瘾,以profile_ + 用戶id的格式存儲更扁。
當(dāng)調(diào)用profile接口時,先檢查緩存是否存在勺鸦,用到了cache.js中的get方法并巍。
如果不存在緩存,則調(diào)接口换途,并寫入緩存懊渡,有效期一小時刽射。
如果存在,直接將緩存中的結(jié)果返回剃执。
總結(jié)
在本文中誓禁,我們從無到有搭建了egg項目,按規(guī)范組織了目錄結(jié)構(gòu)肾档,嘗試寫了第一個接口摹恰,了解了egg的基本寫法。然后接入mysql數(shù)據(jù)庫和JWT怒见,實現(xiàn)了登錄注冊功能俗慈。接著又以用戶信息查詢優(yōu)化展開,學(xué)習(xí)了redis緩存的安裝及使用遣耍。希望對大家能有幫助闺阱。